Completely rewrite commenting components and data model

This XL change consists of many things. Firstly, it converts the three
central commenting components from PolymerElement to LitElement:
<gr-comment>, <gr-comment-thread>, <gr-thread-list>. As usual, that
also means that the components are partially listening to application
wide state through subscriptions instead of getting all the data from
their parents.

The comment threads are still passed down from parent components, but
it is expected that thread lists and thread objects are immutable and
always change as a whole. The advantage is that the components don't
have to watch sub-properties. And Lit handles component re-use and
change detection very well, so there is no performance issue and also
no problem of undesired user-visible re-rendering.

REST API calls around retrieving, modifying and discarding of comments
were moved strictly to the comments-service. The components don't make
backend calls themselves.

The data model was changed such that drafts are now cleanly split up
into DraftInfo for saved drafts and UnsavedInfo for unsaved drafts.
Saved drafts are managed by the comments service and model, and changes
to them are propagated throughout the app. Unsaved drafts are just local
UI state of the comment components.

Storing temporary unsaved comment information in localStorage is removed
in this change. The goal is to replace this by auto-saving comments, so
users can still not lose a substantial amount of data. Ideally auto-
saving will be implemented in a child change and submitted together.

Google-Bug-Id: b/206014286
Change-Id: Ia6d3324516ef5db32a7562852319b645a5bb0f6e
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0029f5c..def693d 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -100,4 +100,6 @@
   ATTENTION_SET_CHIP = 'attention-set-chip',
   SAVE_COMMENT = 'save-comment',
   COMMENT_SAVED = 'comment-saved',
+  DISCARD_COMMENT = 'discard-comment',
+  COMMENT_DISCARDED = 'comment-discarded',
 }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index ee5910f..6ef1e4e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -141,7 +141,7 @@
   isDraftThread,
   isRobot,
   isUnresolved,
-  UIDraft,
+  DraftInfo,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
@@ -368,7 +368,7 @@
   _changeNum?: NumericChangeId;
 
   @property({type: Object})
-  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+  _diffDrafts?: {[path: string]: DraftInfo[]} = {};
 
   @property({type: Boolean})
   _editingCommitMessage = false;
@@ -1531,7 +1531,7 @@
   }
 
   _computeReplyButtonLabel(
-    drafts?: {[path: string]: UIDraft[]},
+    drafts?: {[path: string]: DraftInfo[]},
     canStartReview?: boolean
   ) {
     if (drafts === undefined || canStartReview === undefined) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index eea6d3a..13fb5b3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -565,10 +565,6 @@
         <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          account="[[_account]]"
           comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
           unresolved-only="[[unresolvedOnly]]"
@@ -597,14 +593,7 @@
           value="[[_currentRobotCommentsPatchSet]]"
         >
         </gr-dropdown-list>
-        <gr-thread-list
-          threads="[[_robotCommentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          hide-dropdown
-          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-        >
+        <gr-thread-list threads="[[_robotCommentThreads]]" hide-dropdown>
         </gr-thread-list>
         <template is="dom-if" if="[[_showRobotCommentsButton]]">
           <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 88be4b8..734df6e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -90,6 +90,7 @@
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
+  RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
@@ -101,7 +102,7 @@
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread, UIRobot} from '../../../utils/comment-util';
+import {CommentThread} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
@@ -168,8 +169,6 @@
           message: 'draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -272,8 +271,6 @@
           message: 'resolved draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -920,11 +917,13 @@
     test('only robot comments are rendered', () => {
       assert.equal(element._robotCommentThreads!.length, 2);
       assert.equal(
-        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+        (element._robotCommentThreads![0].comments[0] as RobotCommentInfo)
+          .robot_id,
         'rc1'
       );
       assert.equal(
-        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+        (element._robotCommentThreads![1].comments[0] as RobotCommentInfo)
+          .robot_id,
         'rc2'
       );
     });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index a9b7b81..1778b2b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -125,9 +125,6 @@
       <gr-thread-list
         id="commentList"
         .threads="${this.unresolvedThreads}"
-        .change="${this.change}"
-        .changeNum="${this.change?._number}"
-        logged-in
         hide-dropdown
       >
       </gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 817c21b..38875f5 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -1469,12 +1469,6 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      sinon.stub(diff.changeComments, 'getCommentsForPath')
-          .withArgs('/COMMIT_MSG', {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          })
-          .returns(diff.comments);
       await listenOnce(diff, 'render');
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index a512b60..5d74d07 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -555,8 +555,10 @@
 
   @observe('projectName')
   _projectNameChanged(name?: string) {
-    // Check if name is undefined to prevent errors.
-    if (!name) return;
+    if (!name) {
+      this._projectConfig = undefined;
+      return;
+    }
     this.restApiService.getProjectConfig(name as RepoName).then(config => {
       this._projectConfig = config;
     });
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 7f3e9de..8def279 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -280,13 +280,11 @@
               </div>
             </template>
             <gr-thread-list
-              change="[[change]]"
               hidden$="[[!commentThreads.length]]"
               threads="[[commentThreads]]"
-              change-num="[[changeNum]]"
-              logged-in="[[_loggedIn]]"
               hide-dropdown
               show-comment-context
+              message-id="[[message.id]]"
             >
             </gr-thread-list>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index cedddec..c3ad0b6 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -100,17 +100,10 @@
   message: CombinedMessage,
   allThreadsForChange: CommentThread[]
 ): CommentThread[] {
-  if (message._index === undefined) {
-    return [];
-  }
+  if (message._index === undefined) return [];
   const messageId = getMessageId(message);
   return allThreadsForChange.filter(thread =>
-    thread.comments.some(comment => {
-      const matchesMessage = comment.change_message_id === messageId;
-      if (!matchesMessage) return false;
-      comment.collapsed = !matchesMessage;
-      return matchesMessage;
-    })
+    thread.comments.some(comment => comment.change_message_id === messageId)
   );
 }
 
@@ -392,13 +385,6 @@
       }
     }
 
-    // collapse all by default
-    for (const thread of commentThreads) {
-      for (const comment of thread.comments) {
-        comment.collapsed = true;
-      }
-    }
-
     for (let i = 0; i < combinedMessages.length; i++) {
       const message = combinedMessages[i];
       if (message.expanded === undefined) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 4a8b996..719347c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -394,9 +394,6 @@
         id="commentList"
         hidden$="[[!_includeComments]]"
         threads="[[draftCommentThreads]]"
-        change="[[change]]"
-        change-num="[[change._number]]"
-        logged-in="true"
         hide-dropdown=""
       >
       </gr-thread-list>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index df28175..14ad4ad 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -35,6 +35,7 @@
 import {
   createAccountWithId,
   createChange,
+  createComment,
   createCommentThread,
   createDraft,
   createRevision,
@@ -318,18 +319,13 @@
     if (hasDraft) {
       draftThreads = [
         {
-          ...createCommentThread([
-            {
-              ...createDraft(),
-              __draft: true,
-              unresolved: true,
-            },
-          ]),
+          ...createCommentThread([{...createDraft(), unresolved: true}]),
         },
       ];
     }
     replyToIds?.forEach(id =>
       draftThreads[0].comments.push({
+        ...createComment(),
         author: {_account_id: id},
       })
     );
@@ -878,11 +874,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '1' as UrlEncodedCommentId,
             author: {_account_id: 1 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '2' as UrlEncodedCommentId,
             in_reply_to: '1' as UrlEncodedCommentId,
             author: {_account_id: 2 as AccountId},
@@ -893,11 +891,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '3' as UrlEncodedCommentId,
             author: {_account_id: 3 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '4' as UrlEncodedCommentId,
             in_reply_to: '3' as UrlEncodedCommentId,
             author: {_account_id: 4 as AccountId},
@@ -2003,7 +2003,7 @@
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [
-          {...createCommentThread([{__draft: true}])},
+          {...createCommentThread([createComment()])},
         ],
         /* text= */ '',
         /* reviewersMutated= */ false,
@@ -2023,7 +2023,7 @@
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [
-          {...createCommentThread([{__draft: true}])},
+          {...createCommentThread([createComment()])},
         ],
         /* text= */ '',
         /* reviewersMutated= */ false,
@@ -2042,7 +2042,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ 'test',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ false,
@@ -2060,7 +2062,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ true,
         /* labelsChanged= */ false,
@@ -2078,7 +2082,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ true,
@@ -2096,7 +2102,9 @@
     assert.isTrue(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ true,
@@ -2120,7 +2128,9 @@
     assert.isFalse(
       element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* draftCommentThreads= */ [
+          {...createCommentThread([createComment()])},
+        ],
         /* text= */ '',
         /* reviewersMutated= */ false,
         /* labelsChanged= */ false,
@@ -2144,7 +2154,12 @@
     element.draftCommentThreads = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as PatchSetNum,
+          },
         ]),
       },
     ];
@@ -2167,7 +2182,12 @@
     element.draftCommentThreads = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as PatchSetNum,
+          },
         ]),
       },
     ];
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 87c62b1..41e2fc4e 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -17,50 +17,37 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-thread-list_html';
-import {parseDate} from '../../../utils/date-util';
-
-import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {computed, customElement, observe, property} from '@polymer/decorators';
-import {
-  PolymerSpliceChange,
-  PolymerDeepPropertyChange,
-} from '@polymer/polymer/interfaces';
+import {SpecialFilePath} from '../../../constants/constants';
 import {
   AccountDetailInfo,
   AccountInfo,
-  ChangeInfo,
   NumericChangeId,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {
   CommentThread,
-  isDraft,
-  isUnresolved,
+  getCommentAuthors,
+  hasHumanReply,
   isDraftThread,
   isRobotThread,
-  hasHumanReply,
-  getCommentAuthors,
-  computeId,
-  UIComment,
+  isUnresolved,
+  lastUpdated,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined, assertNever} from '../../../utils/common-util';
+import {assertIsDefined} from '../../../utils/common-util';
 import {CommentTabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-interface CommentThreadWithInfo {
-  thread: CommentThread;
-  hasRobotComment: boolean;
-  hasHumanReplyToRobotComment: boolean;
-  unresolved: boolean;
-  isEditing: boolean;
-  hasDraft: boolean;
-  updated?: Date;
-}
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, queryAll, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {change$, changeNum$} from '../../../services/change/change-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {repeat} from 'lit/directives/repeat';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {getAppContext} from '../../../services/app-context';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -69,573 +56,505 @@
 
 export const __testOnly_SortDropdownState = SortDropdownState;
 
-@customElement('gr-thread-list')
-export class GrThreadList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+/**
+ * Order as follows:
+ * - Patchset level threads (descending based on patchset number)
+ * - unresolved
+ * - comments with drafts
+ * - comments without drafts
+ * - resolved
+ * - comments with drafts
+ * - comments without drafts
+ * - File name
+ * - Line number
+ * - Unresolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ * - Resolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ */
+export function compareThreads(
+  c1: CommentThread,
+  c2: CommentThread,
+  byTimestamp = false
+) {
+  if (byTimestamp) {
+    const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+    const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+    const timeDiff = c2Time - c1Time;
+    if (timeDiff !== 0) return c2Time - c1Time;
   }
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  if (c1.path !== c2.path) {
+    // '/PATCHSET' will not come before '/COMMIT' when sorting
+    // alphabetically so move it to the front explicitly
+    if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return -1;
+    }
+    if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return 1;
+    }
+    return c1.path.localeCompare(c2.path);
+  }
 
+  // Convert 'FILE' and 'LOST' to undefined.
+  const line1 = typeof c1.line === 'number' ? c1.line : undefined;
+  const line2 = typeof c2.line === 'number' ? c2.line : undefined;
+  if (line1 !== line2) {
+    // one of them is a FILE/LOST comment, show first
+    if (line1 === undefined) return -1;
+    if (line2 === undefined) return 1;
+    // Lower line numbers first.
+    return line1 < line2 ? -1 : 1;
+  }
+
+  if (c1.patchNum !== c2.patchNum) {
+    // `patchNum` should be required, but show undefined first.
+    if (c1.patchNum === undefined) return -1;
+    if (c2.patchNum === undefined) return 1;
+    // Higher patchset numbers first.
+    return c1.patchNum > c2.patchNum ? -1 : 1;
+  }
+
+  // Sorting should not be based on the thread being unresolved or being a draft
+  // thread, because that would be a surprising re-sort when the thread changes
+  // state.
+
+  const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+  const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+  if (c2Time !== c1Time) {
+    // Newer comments first.
+    return c2Time - c1Time;
+  }
+
+  return 0;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends LitElement {
+  @queryAll('gr-comment-thread')
+  threadElements?: NodeList;
+
+  /**
+   * Raw list of threads for the component to show.
+   *
+   * ATTENTION! this.threads should never be used directly within the component.
+   *
+   * Either use getAllThreads(), which applies filters that are inherent to what
+   * the component is supposed to render,
+   * e.g. onlyShowRobotCommentsWithHumanReply.
+   *
+   * Or use getDisplayedThreads(), which applies the currently selected filters
+   * on top.
+   */
   @property({type: Array})
   threads: CommentThread[] = [];
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Array})
-  _sortedThreads: CommentThread[] = [];
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-comment-context'})
   showCommentContext = false;
 
-  @property({
-    computed:
-      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
-    type: Array,
-  })
-  _displayedThreads: CommentThread[] = [];
-
-  // thread-list is used in multiple places like the change log, hence
-  // keeping the default to be false. When used in comments tab, it's
-  // set as true.
-  @property({type: Boolean})
+  /** Along with `draftsOnly` is the currently selected filter. */
+  @property({type: Boolean, attribute: 'unresolved-only'})
   unresolvedOnly = false;
 
-  @property({type: Boolean})
-  _draftsOnly = false;
-
-  @property({type: Boolean})
+  @property({
+    type: Boolean,
+    attribute: 'only-show-robot-comments-with-human-reply',
+  })
   onlyShowRobotCommentsWithHumanReply = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-dropdown'})
   hideDropdown = false;
 
-  @property({type: Object, observer: '_commentTabStateChange'})
+  @property({type: Object, attribute: 'comment-tab-state'})
   commentTabState?: CommentTabState;
 
-  @property({type: Object})
-  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
-
-  @property({type: Array, notify: true})
-  selectedAuthors: AccountInfo[] = [];
-
-  @property({type: Object})
-  account?: AccountDetailInfo;
-
-  @computed('unresolvedOnly', '_draftsOnly')
-  get commentsDropdownValue() {
-    // set initial value and triggered when comment summary chips are clicked
-    if (this._draftsOnly) return CommentTabState.DRAFTS;
-    return this.unresolvedOnly
-      ? CommentTabState.UNRESOLVED
-      : CommentTabState.SHOW_ALL;
-  }
-
-  @property({type: String})
+  @property({type: String, attribute: 'scroll-comment-id'})
   scrollCommentId?: UrlEncodedCommentId;
 
-  _showEmptyThreadsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    if (!threads || !displayedThreads) return false;
-    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  /**
+   * Optional context information when threads are being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  change?: ParsedChangeInfo;
+
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  selectedAuthors: AccountInfo[] = [];
+
+  @state()
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  /** Along with `unresolvedOnly` is the currently selected filter. */
+  @state()
+  draftsOnly = false;
+
+  private readonly userModel = getAppContext().userModel;
+
+  constructor() {
+    super();
+    subscribe(this, changeNum$, x => (this.changeNum = x));
+    subscribe(this, change$, x => (this.change = x));
+    subscribe(this, this.userModel.account$, x => (this.account = x));
   }
 
-  _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments' : 'No unresolved comments';
+  override willUpdate(changed: PropertyValues) {
+    if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
   }
 
-  _showPartyPopper(threads: CommentThread[]) {
-    return !!threads.length;
-  }
-
-  _computeResolvedCommentsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean,
-    onlyShowRobotCommentsWithHumanReply: boolean
-  ) {
-    if (onlyShowRobotCommentsWithHumanReply) {
-      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+  private onCommentTabStateUpdate() {
+    switch (this.commentTabState) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      case CommentTabState.SHOW_ALL:
+        this.handleAllComments();
+        break;
     }
-    if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return `Show ${pluralize(threads.length, 'resolved comment')}`;
-    }
-    return '';
   }
 
-  _showResolvedCommentsButton(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    return unresolvedOnly && threads.length && !displayedThreads.length;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #threads {
+          display: block;
+        }
+        gr-comment-thread {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          border-bottom: 1px solid var(--border-color);
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          justify-content: left;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+        .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+        .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+          display: block;
+        }
+        .thread-separator {
+          border-top: 1px solid var(--border-color);
+          margin-top: var(--spacing-xl);
+        }
+        .show-resolved-comments {
+          box-shadow: none;
+          padding-left: var(--spacing-m);
+        }
+        .partypopper {
+          margin-right: var(--spacing-s);
+        }
+        gr-dropdown-list {
+          --trigger-style-text-color: var(--primary-text-color);
+          --trigger-style-font-family: var(--font-family);
+        }
+        .filter-text,
+        .sort-text,
+        .author-text {
+          margin-right: var(--spacing-s);
+          color: var(--deemphasized-text-color);
+        }
+        .author-text {
+          margin-left: var(--spacing-m);
+        }
+        gr-account-label {
+          --account-max-length: 120px;
+          display: inline-block;
+          user-select: none;
+          --label-border-radius: 8px;
+          margin: 0 var(--spacing-xs);
+          padding: var(--spacing-xs) var(--spacing-m);
+          line-height: var(--line-height-normal);
+          cursor: pointer;
+        }
+        gr-account-label:focus {
+          outline: none;
+        }
+        gr-account-label:hover,
+        gr-account-label:hover {
+          box-shadow: var(--elevation-level-1);
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  _handleResolvedCommentsMessageClick() {
-    this.unresolvedOnly = !this.unresolvedOnly;
+  override render() {
+    return html`
+      ${this.renderDropdown()}
+      <div id="threads" part="threads">
+        ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+      </div>
+    `;
   }
 
-  getSortDropdownEntires() {
+  private renderDropdown() {
+    if (this.hideDropdown) return;
+    return html`
+      <div class="header">
+        <span class="sort-text">Sort By:</span>
+        <gr-dropdown-list
+          id="sortDropdown"
+          .value="${this.sortDropdownValue}"
+          @value-change="${(e: CustomEvent) =>
+            (this.sortDropdownValue = e.detail.value)}"
+          .items="${this.getSortDropdownEntries()}"
+        >
+        </gr-dropdown-list>
+        <span class="separator"></span>
+        <span class="filter-text">Filter By:</span>
+        <gr-dropdown-list
+          id="filterDropdown"
+          .value="${this.getCommentsDropdownValue()}"
+          @value-change="${this.handleCommentsDropdownValueChange}"
+          .items="${this.getCommentsDropdownEntries()}"
+        >
+        </gr-dropdown-list>
+        ${this.renderAuthorChips()}
+      </div>
+    `;
+  }
+
+  private renderEmptyThreadsMessage() {
+    const threads = this.getAllThreads();
+    const threadsEmpty = threads.length === 0;
+    const displayedEmpty = this.getDisplayedThreads().length === 0;
+    if (!displayedEmpty) return;
+    const showPopper = this.unresolvedOnly && !threadsEmpty;
+    const popper = html`<span class="partypopper">&#x1F389;</span>`;
+    const showButton = this.unresolvedOnly && !threadsEmpty;
+    const button = html`
+      <gr-button
+        class="show-resolved-comments"
+        link
+        @click="${this.handleAllComments}"
+        >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
+      >
+    `;
+    return html`
+      <div>
+        <span>
+          ${showPopper ? popper : undefined}
+          ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
+          ${showButton ? button : undefined}
+        </span>
+      </div>
+    `;
+  }
+
+  private renderCommentThreads() {
+    const threads = this.getDisplayedThreads();
+    return repeat(
+      threads,
+      thread => thread.rootId,
+      (thread, index) => {
+        const isFirst =
+          index === 0 || threads[index - 1].path !== threads[index].path;
+        const separator =
+          index !== 0 && isFirst
+            ? html`<div class="thread-separator"></div>`
+            : undefined;
+        const commentThread = this.renderCommentThread(thread, isFirst);
+        return html`${separator}${commentThread}`;
+      }
+    );
+  }
+
+  private renderCommentThread(thread: CommentThread, isFirst: boolean) {
+    return html`
+      <gr-comment-thread
+        .thread="${thread}"
+        show-file-path
+        ?show-ported-comment="${thread.ported}"
+        ?show-comment-context="${this.showCommentContext}"
+        ?show-file-name="${isFirst}"
+        .messageId="${this.messageId}"
+        ?should-scroll-into-view="${thread.rootId === this.scrollCommentId}"
+        @comment-thread-editing-changed="${() => {
+          this.requestUpdate();
+        }}"
+      ></gr-comment-thread>
+    `;
+  }
+
+  private renderAuthorChips() {
+    const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
+    if (authors.length === 0) return;
+    return html`<span class="author-text">From:</span>${authors.map(author =>
+        this.renderAccountChip(author)
+      )}`;
+  }
+
+  private renderAccountChip(account: AccountInfo) {
+    const selected = this.selectedAuthors.some(
+      a => a._account_id === account._account_id
+    );
+    return html`
+      <gr-account-label
+        .account="${account}"
+        @click="${this.handleAccountClicked}"
+        selectionChipStyle
+        ?selected="${selected}"
+      ></gr-account-label>
+    `;
+  }
+
+  private getCommentsDropdownValue() {
+    if (this.draftsOnly) return CommentTabState.DRAFTS;
+    if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
+    return CommentTabState.SHOW_ALL;
+  }
+
+  private getSortDropdownEntries() {
     return [
       {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
       {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
     ];
   }
 
-  getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
-    const items: DropdownItem[] = [
-      {
-        text: `Unresolved (${this._countUnresolved(threads)})`,
-        value: CommentTabState.UNRESOLVED,
-      },
-      {
-        text: `All (${this._countAllThreads(threads)})`,
-        value: CommentTabState.SHOW_ALL,
-      },
-    ];
-    if (loggedIn)
-      items.splice(1, 0, {
-        text: `Drafts (${this._countDrafts(threads)})`,
+  // private, but visible for testing
+  getCommentsDropdownEntries() {
+    const items: DropdownItem[] = [];
+    const threads = this.getAllThreads();
+    items.push({
+      text: `Unresolved (${threads.filter(isUnresolved).length})`,
+      value: CommentTabState.UNRESOLVED,
+    });
+    if (this.account) {
+      items.push({
+        text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
       });
+    }
+    items.push({
+      text: `All (${threads.length})`,
+      value: CommentTabState.SHOW_ALL,
+    });
     return items;
   }
 
-  getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
-    return getCommentAuthors(threads, account);
-  }
-
-  handleAccountClicked(e: MouseEvent) {
+  private handleAccountClicked(e: MouseEvent) {
     const account = (e.target as GrAccountChip).account;
     assertIsDefined(account, 'account');
-    const index = this.selectedAuthors.findIndex(
-      author => author._account_id === account._account_id
-    );
-    if (index === -1) this.push('selectedAuthors', account);
-    else this.splice('selectedAuthors', index, 1);
-    // re-assign so that isSelected template method is called
-    this.selectedAuthors = [...this.selectedAuthors];
+    const predicate = (a: AccountInfo) => a._account_id === account._account_id;
+    const found = this.selectedAuthors.find(predicate);
+    if (found) {
+      this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
+    } else {
+      this.selectedAuthors = [...this.selectedAuthors, account];
+    }
   }
 
-  isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
-    return selectedAuthors.some(a => a._account_id === author._account_id);
-  }
-
-  computeShouldScrollIntoView(
-    comments: UIComment[],
-    scrollCommentId?: UrlEncodedCommentId
-  ) {
-    const comment = comments?.[0];
-    if (!comment) return false;
-    return computeId(comment) === scrollCommentId;
-  }
-
-  handleSortDropdownValueChange(e: CustomEvent) {
-    this.sortDropdownValue = e.detail.value;
-    /*
-     * Ideally we would have updateSortedThreads observe on sortDropdownValue
-     * but the method triggered re-render only when the length of threads
-     * changes, hence keep the explicit resortThreads method
-     */
-    this.resortThreads(this.threads);
-  }
-
+  // private, but visible for testing
   handleCommentsDropdownValueChange(e: CustomEvent) {
     const value = e.detail.value;
-    if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
-    else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
-    else this._handleAllComments();
-  }
-
-  _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
-    if (
-      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
-      !this.hideDropdown
-    ) {
-      // In case of equal timestamps we want futher ordering
-      if (c1.updated && c2.updated && c1.updated !== c2.updated)
-        return c1.updated > c2.updated ? -1 : 1;
+    switch (value) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      default:
+        this.handleAllComments();
     }
-
-    if (c1.thread.path !== c2.thread.path) {
-      // '/PATCHSET' will not come before '/COMMIT' when sorting
-      // alphabetically so move it to the front explicitly
-      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return -1;
-      }
-      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return 1;
-      }
-      return c1.thread.path.localeCompare(c2.thread.path);
-    }
-
-    // Patchset comments have no line/range associated with them
-    if (c1.thread.line !== c2.thread.line) {
-      if (!c1.thread.line || !c2.thread.line) {
-        // one of them is a file level comment, show first
-        return c1.thread.line ? 1 : -1;
-      }
-      return c1.thread.line < c2.thread.line ? -1 : 1;
-    }
-
-    if (c1.thread.patchNum !== c2.thread.patchNum) {
-      if (!c1.thread.patchNum) return 1;
-      if (!c2.thread.patchNum) return -1;
-      // Threads left on Base when comparing Base vs X have patchNum = X
-      // and CommentSide = PARENT
-      // Threads left on 'edit' have patchNum set as latestPatchNum
-      return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
-    }
-
-    if (c2.unresolved !== c1.unresolved) {
-      if (!c1.unresolved) return 1;
-      if (!c2.unresolved) return -1;
-    }
-
-    if (c2.hasDraft !== c1.hasDraft) {
-      if (!c1.hasDraft) return 1;
-      if (!c2.hasDraft) return -1;
-    }
-
-    if (c2.updated !== c1.updated) {
-      if (!c1.updated) return 1;
-      if (!c2.updated) return -1;
-      return c2.updated.getTime() - c1.updated.getTime();
-    }
-
-    if (c2.thread.rootId !== c1.thread.rootId) {
-      if (!c1.thread.rootId) return 1;
-      if (!c2.thread.rootId) return -1;
-      return c1.thread.rootId.localeCompare(c2.thread.rootId);
-    }
-
-    return 0;
-  }
-
-  resortThreads(threads: CommentThread[]) {
-    const threadsWithInfo = threads.map(thread =>
-      this._getThreadWithStatusInfo(thread)
-    );
-    this._sortedThreads = threadsWithInfo
-      .sort((t1, t2) => this._compareThreads(t1, t2))
-      .map(threadInfo => threadInfo.thread);
   }
 
   /**
-   * Observer on threads and update _sortedThreads when needed.
-   * Order as follows:
-   * - Patchset level threads (descending based on patchset number)
-   * - unresolved
-   * - comments with drafts
-   * - comments without drafts
-   * - resolved
-   * - comments with drafts
-   * - comments without drafts
-   * - File name
-   * - Line number
-   * - Unresolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   * - Resolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   *
-   * @param threads
-   * @param spliceRecord
+   * Returns all threads that the list may show.
    */
-  @observe('threads', 'threads.splices')
-  _updateSortedThreads(
-    threads: CommentThread[],
-    _: PolymerSpliceChange<CommentThread[]>
-  ) {
-    if (!threads || threads.length === 0) {
-      this._sortedThreads = [];
-      this._displayedThreads = [];
-      return;
-    }
-    // We only want to sort on thread additions / removals to avoid
-    // re-rendering on modifications (add new reply / edit draft etc.).
-    // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
-    // TODO(TS): We have removed a buggy check of the splices here. A splice
-    // with addedCount > 0 or removed.length > 0 should also cause re-sorting
-    // and re-rendering, but apparently spliceRecord is always undefined for
-    // whatever reason.
-    // If there is an unsaved draftThread which is supposed to be replaced with
-    // a saved draftThread then resort all threads
-    const unsavedThread = this._sortedThreads.some(thread =>
-      thread.rootId?.includes('draft__')
-    );
-    if (this._sortedThreads.length === threads.length && !unsavedThread) {
-      // Instead of replacing the _sortedThreads which will trigger a re-render,
-      // we override all threads inside of it.
-      for (const thread of threads) {
-        const idxInSortedThreads = this._sortedThreads.findIndex(
-          t => t.rootId === thread.rootId
-        );
-        this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
-      }
-      return;
-    }
-
-    this.resortThreads(threads);
-  }
-
-  _computeDisplayedThreads(
-    sortedThreadsRecord?: PolymerDeepPropertyChange<
-      CommentThread[],
-      CommentThread[]
-    >,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
-    return sortedThreadsRecord.base.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  // private, but visible for testing
+  getAllThreads() {
+    return this.threads.filter(
+      t =>
+        !this.onlyShowRobotCommentsWithHumanReply ||
+        !isRobotThread(t) ||
+        hasHumanReply(t)
     );
   }
 
-  _isFirstThreadWithFileName(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return index === 0 || threads[index - 1].path !== threads[index].path;
+  /**
+   * Returns all threads that are currently shown in the list, respecting the
+   * currently selected filter.
+   */
+  // private, but visible for testing
+  getDisplayedThreads() {
+    const byTimestamp =
+      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+      !this.hideDropdown;
+    return this.getAllThreads()
+      .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
+      .filter(t => this.shouldShowThread(t));
   }
 
-  _shouldRenderSeparator(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return (
-      index > 0 &&
-      this._isFirstThreadWithFileName(
-        displayedThreads,
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  private isASelectedAuthor(account?: AccountInfo) {
+    if (!account) return false;
+    return this.selectedAuthors.some(
+      author => account._account_id === author._account_id
     );
   }
 
-  _shouldShowThread(
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (
-      [
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors,
-      ].includes(undefined)
-    ) {
-      return false;
+  private shouldShowThread(thread: CommentThread) {
+    // Never make a thread disappear while the user is editing it.
+    assertIsDefined(thread.rootId, 'thread.rootId');
+    const el = this.queryThreadElement(thread.rootId);
+    if (el?.editing) return true;
+
+    if (this.selectedAuthors.length > 0) {
+      const hasACommentFromASelectedAuthor = thread.comments.some(c =>
+        this.isASelectedAuthor(c.author)
+      );
+      if (!hasACommentFromASelectedAuthor) return false;
     }
 
-    if (selectedAuthors!.length) {
-      if (
-        !thread.comments.some(
-          c =>
-            c.author &&
-            selectedAuthors!.some(
-              author => c.author!._account_id === author._account_id
-            )
-        )
-      ) {
-        return false;
-      }
+    // This is probably redundant, because getAllThreads() filters this out.
+    if (this.onlyShowRobotCommentsWithHumanReply) {
+      if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
     }
 
-    if (
-      !draftsOnly &&
-      !unresolvedOnly &&
-      !onlyShowRobotCommentsWithHumanReply
-    ) {
-      return true;
-    }
+    if (this.draftsOnly && !isDraftThread(thread)) return false;
+    if (this.unresolvedOnly && !isUnresolved(thread)) return false;
 
-    const threadInfo = this._getThreadWithStatusInfo(thread);
-
-    if (threadInfo.isEditing) {
-      return true;
-    }
-
-    if (
-      threadInfo.hasRobotComment &&
-      onlyShowRobotCommentsWithHumanReply &&
-      !threadInfo.hasHumanReplyToRobotComment
-    ) {
-      return false;
-    }
-
-    let filtersCheck = true;
-    if (draftsOnly && unresolvedOnly) {
-      filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
-    } else if (draftsOnly) {
-      filtersCheck = threadInfo.hasDraft;
-    } else if (unresolvedOnly) {
-      filtersCheck = threadInfo.unresolved;
-    }
-
-    return filtersCheck;
+    return true;
   }
 
-  _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
-    const comments = thread.comments;
-    const lastComment = comments.length
-      ? comments[comments.length - 1]
-      : undefined;
-    const hasRobotComment = isRobotThread(thread);
-    const hasHumanReplyToRobotComment =
-      hasRobotComment && hasHumanReply(thread);
-    let updated = undefined;
-    if (lastComment) {
-      if (isDraft(lastComment)) updated = lastComment.__date;
-      if (lastComment.updated) updated = parseDate(lastComment.updated);
-    }
-
-    return {
-      thread,
-      hasRobotComment,
-      hasHumanReplyToRobotComment,
-      unresolved: !!lastComment && !!lastComment.unresolved,
-      isEditing: isDraft(lastComment) && !!lastComment.__editing,
-      hasDraft: !!lastComment && isDraft(lastComment),
-      updated,
-    };
-  }
-
-  _isOnParent(side?: CommentSide) {
-    // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
-    // classified as parent??
-    return !!side;
-  }
-
-  _handleOnlyUnresolved() {
+  private handleOnlyUnresolved() {
     this.unresolvedOnly = true;
-    this._draftsOnly = false;
+    this.draftsOnly = false;
   }
 
-  _handleOnlyDrafts() {
-    this._draftsOnly = true;
+  private handleOnlyDrafts() {
+    this.draftsOnly = true;
     this.unresolvedOnly = false;
   }
 
-  _handleAllComments() {
-    this._draftsOnly = false;
+  private handleAllComments() {
+    this.draftsOnly = false;
     this.unresolvedOnly = false;
   }
 
-  _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
-    return !draftsOnly && !unresolvedOnly;
-  }
-
-  _countUnresolved(threads?: CommentThread[]) {
-    return (
-      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
-        .length ?? 0
-    );
-  }
-
-  _countAllThreads(threads?: CommentThread[]) {
-    return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
-  }
-
-  _countDrafts(threads?: CommentThread[]) {
-    return (
-      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
-        .length ?? 0
-    );
-  }
-
-  filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
-    return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
-  }
-
-  _commentTabStateChange(
-    newValue?: CommentTabState,
-    oldValue?: CommentTabState
-  ) {
-    if (!newValue || newValue === oldValue) return;
-    let focusTo: string | undefined;
-    switch (newValue) {
-      case CommentTabState.UNRESOLVED:
-        this._handleOnlyUnresolved();
-        // input is null because it's not rendered yet.
-        focusTo = '#unresolvedRadio';
-        break;
-      case CommentTabState.DRAFTS:
-        this._handleOnlyDrafts();
-        focusTo = '#draftsRadio';
-        break;
-      case CommentTabState.SHOW_ALL:
-        this._handleAllComments();
-        focusTo = '#allRadio';
-        break;
-      default:
-        assertNever(newValue, 'Unsupported preferred state');
-    }
-    const selector = focusTo;
-    window.setTimeout(() => {
-      const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector);
-      input?.focus();
-    }, 0);
+  private queryThreadElement(rootId: string): GrCommentThread | undefined {
+    const els = [...(this.threadElements ?? [])] as GrCommentThread[];
+    return els.find(el => el.rootId === rootId);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
deleted file mode 100644
index 3eb28c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #threads {
-      display: block;
-    }
-    gr-comment-thread {
-      display: block;
-      margin-bottom: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      border-bottom: 1px solid var(--border-color);
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: left;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
-    .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
-    .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
-      display: block;
-    }
-    .thread-separator {
-      border-top: 1px solid var(--border-color);
-      margin-top: var(--spacing-xl);
-    }
-    .show-resolved-comments {
-      box-shadow: none;
-      padding-left: var(--spacing-m);
-    }
-    .partypopper{
-      margin-right: var(--spacing-s);
-    }
-    gr-dropdown-list {
-      --trigger-style-text-color: var(--primary-text-color);
-      --trigger-style-font-family: var(--font-family);
-    }
-    .filter-text, .sort-text, .author-text {
-      margin-right: var(--spacing-s);
-      color: var(--deemphasized-text-color);
-    }
-    .author-text {
-      margin-left: var(--spacing-m);
-    }
-    gr-account-label {
-      --account-max-length: 120px;
-      display: inline-block;
-      user-select: none;
-      --label-border-radius: 8px;
-      margin: 0 var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-m);
-      line-height: var(--line-height-normal);
-      cursor: pointer;
-    }
-    gr-account-label:focus {
-      outline: none;
-    }
-    gr-account-label:hover,
-    gr-account-label:hover {
-      box-shadow: var(--elevation-level-1);
-      cursor: pointer;
-    }
-  </style>
-  <template is="dom-if" if="[[!hideDropdown]]">
-    <div class="header">
-      <span class="sort-text">Sort By:</span>
-      <gr-dropdown-list
-        id="sortDropdown"
-        value="[[sortDropdownValue]]"
-        on-value-change="handleSortDropdownValueChange"
-        items="[[getSortDropdownEntires()]]"
-      >
-      </gr-dropdown-list>
-      <span class="separator"></span>
-      <span class="filter-text">Filter By:</span>
-      <gr-dropdown-list
-        id="filterDropdown"
-        value="[[commentsDropdownValue]]"
-        on-value-change="handleCommentsDropdownValueChange"
-        items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
-      >
-      </gr-dropdown-list>
-      <template is="dom-if" if="[[_displayedThreads.length]]">
-        <span class="author-text">From:</span>
-        <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
-          <gr-account-label
-            account="[[item]]"
-            on-click="handleAccountClicked"
-            selectionChipStyle
-            selected="[[isSelected(item, selectedAuthors)]]"
-          > </gr-account-label>
-        </template>
-      </template>
-    </div>
-  </template>
-  <div id="threads" part="threads">
-    <template
-      is="dom-if"
-      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
-    >
-      <div>
-        <span>
-          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
-            <span class="partypopper">\&#x1F389</span>
-          </template>
-          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
-          unresolvedOnly)]]
-          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
-            <gr-button
-              class="show-resolved-comments"
-              link
-              on-click="_handleResolvedCommentsMessageClick">
-                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
-            </gr-button>
-          </template>
-        </span>
-      </div>
-    </template>
-    <template
-      is="dom-repeat"
-      items="[[_displayedThreads]]"
-      as="thread"
-      initial-count="10"
-      target-framerate="60"
-    >
-      <template
-        is="dom-if"
-        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-      >
-        <div class="thread-separator"></div>
-      </template>
-      <gr-comment-thread
-        show-file-path=""
-        show-ported-comment="[[thread.ported]]"
-        show-comment-context="[[showCommentContext]]"
-        change-num="[[changeNum]]"
-        comments="[[thread.comments]]"
-        diff-side="[[thread.diffSide]]"
-        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-        project-name="[[change.project]]"
-        is-on-parent="[[_isOnParent(thread.commentSide)]]"
-        line-num="[[thread.line]]"
-        patch-num="[[thread.patchNum]]"
-        path="[[thread.path]]"
-        root-id="{{thread.rootId}}"
-        should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
-      ></gr-comment-thread>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
deleted file mode 100644
index aab5cee..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ /dev/null
@@ -1,673 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-thread-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {CommentTabState} from '../../../types/events.js';
-import {__testOnly_SortDropdownState} from './gr-thread-list.js';
-import {queryAll} from '../../../test/test-utils.js';
-import {accountOrGroupKey} from '../../../utils/account-util.js';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-thread-list');
-
-suite('gr-thread-list tests', () => {
-  let element;
-
-  function getVisibleThreads() {
-    return [...dom(element.root)
-        .querySelectorAll('gr-comment-thread')]
-        .filter(e => e.style.display !== 'none');
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.changeNum = 123;
-    element.change = {
-      project: 'testRepo',
-    };
-    element.threads = [
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000001,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 5,
-            updated: '1',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            id: '503008e2_0ab203ee',
-            path: '/COMMIT_MSG',
-            line: 5,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '1',
-            message: 'draft',
-            unresolved: true,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'ecf0b9fa_fe1a5f62',
-        updated: '1',
-      },
-      {
-        comments: [
-          {
-            path: 'test.txt',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: '09a9fb0a_1484e6cf',
-            side: 'PARENT',
-            updated: '2',
-            message: 'Some comment on another patchset.',
-            unresolved: false,
-          },
-        ],
-        patchNum: 3,
-        path: 'test.txt',
-        rootId: '09a9fb0a_1484e6cf',
-        updated: '2',
-        commentSide: 'PARENT',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: '8caddf38_44770ec1',
-            updated: '3',
-            message: 'Another unresolved comment',
-            unresolved: false,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        rootId: '8caddf38_44770ec1',
-        updated: '3',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000003,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: 'scaddf38_44770ec1',
-            line: 4,
-            updated: '4',
-            message: 'Yet another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: 'scaddf38_44770ec1',
-        updated: '4',
-      },
-      {
-        comments: [
-          {
-            id: 'zcf0b9fa_fe1a5f62',
-            path: '/COMMIT_MSG',
-            line: 6,
-            updated: '5',
-            message: 'resolved draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff69',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 6,
-        rootId: 'zcf0b9fa_fe1a5f62',
-        updated: '5',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_1',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '6',
-            message: 'patchset comment 1',
-            unresolved: false,
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 2,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_1',
-        updated: '6',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_2',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '7',
-            message: 'patchset comment 2',
-            unresolved: false,
-            __editing: false,
-            patch_set: '3',
-          },
-        ],
-        patchNum: 3,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_2',
-        updated: '7',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc1',
-            line: 5,
-            updated: '8',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc1',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc1',
-        updated: '8',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc2',
-            line: 7,
-            updated: '9',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc2',
-          },
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'c2_1',
-            line: 5,
-            updated: '10',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 7,
-        rootId: 'rc2',
-        updated: '10',
-      },
-    ];
-
-    // use flush to render all (bypass initial-count set on dom-repeat)
-    await flush();
-  });
-
-  test('draft dropdown item only appears when logged in', () => {
-    element.loggedIn = false;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 2);
-    element.loggedIn = true;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 3);
-  });
-
-  test('show all threads by default', () => {
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, element.threads.length);
-    assert.equal(getVisibleThreads().length, element.threads.length);
-  });
-
-  test('show unresolved threads if unresolvedOnly is set', async () => {
-    element.unresolvedOnly = true;
-    await flush();
-    const unresolvedThreads = element.threads.filter(t => t.comments.some(
-        c => c.unresolved
-    ));
-    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
-  });
-
-  test('showing file name takes visible threads into account', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    true);
-    element.unresolvedOnly = true;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    false);
-  });
-
-  test('onlyShowRobotCommentsWithHumanReply ', () => {
-    element.onlyShowRobotCommentsWithHumanReply = true;
-    flush();
-    assert.equal(
-        getVisibleThreads().length,
-        element.threads.length - 1);
-    assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
-  });
-
-  suite('_compareThreads', () => {
-    setup(() => {
-      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    });
-
-    test('patchset comes before any other file', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
-      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
-
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      // assigning values to properties such that t2 should come first
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file path is compared lexicographically', () => {
-      const t1 = {thread: {path: 'a.txt'}};
-      const t2 = {thread: {path: 'b.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('patchset comments sorted by reverse patchset', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('patchset comments with same patchset picks unresolved first', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: true};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: false};
-      t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file level comment before line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments sorted by line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt', line: 3}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('comments on same line sorted by reverse patchset', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
-      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments on same line & patchset sorted by unresolved first',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: false};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-
-          t2.hasDraft = true;
-          t1.hasDraft = false;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-        });
-
-    test('comments on same line & patchset & unresolved sorted by draft',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: false};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: true};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), 1);
-          assert.equal(element._compareThreads(t2, t1), -1);
-        });
-  });
-
-  test('_computeSortedThreads', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'patchset_level_2', // Posted on Patchset 3
-      'patchset_level_1', // Posted on Patchset 2
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('_computeSortedThreads with timestamp', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
-    element.resortThreads(element.threads);
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'rc2',
-      'rc1',
-      'patchset_level_2',
-      'patchset_level_1',
-      'zcf0b9fa_fe1a5f62',
-      'scaddf38_44770ec1',
-      '8caddf38_44770ec1',
-      '09a9fb0a_1484e6cf',
-      'ecf0b9fa_fe1a5f62',
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('tapping single author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-    const authors = chips.map(
-        chip => accountOrGroupKey(chip.account))
-        .sort();
-    assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-
-    // accountId 1000001
-    const chip = chips.find(chip => chip.account._account_id === 1000001);
-
-    tap(chip);
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 1);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000001);
-
-    tap(chip); // tapping again resets
-    flush();
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-  });
-
-  test('tapping multiple author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-
-    tap(chips.find(chip => chip.account._account_id === 1000001));
-    tap(chips.find(chip => chip.account._account_id === 1000002));
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 3);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[1].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[2].comments[0].author._account_id,
-        1000001);
-  });
-
-  test('thread removal and sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const index = element.threads.findIndex(t => t.rootId === 'rc2');
-    element.threads.splice(index, 1);
-    element.threads = [...element.threads]; // trigger observers
-    flush();
-    assert.equal(element._sortedThreads.length, 8);
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('modification on thread shold not trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const currentSortedThreads = [...element._sortedThreads];
-    for (const thread of currentSortedThreads) {
-      thread.comments = [...thread.comments];
-    }
-    const modifiedThreads = [...element.threads];
-    modifiedThreads[5] = {...modifiedThreads[5]};
-    modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
-      ...modifiedThreads[5].comments[0],
-      unresolved: false,
-    }];
-    element.threads = modifiedThreads;
-    assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('reset sortedThreads when threads set to undefiend', () => {
-    element.threads = undefined;
-    assert.deepEqual(element._sortedThreads, []);
-  });
-
-  test('non-equal length of sortThreads and threads' +
-    ' should trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const modifiedThreads = [...element.threads];
-    const currentSortedThreads = [...element._sortedThreads];
-    element._sortedThreads = [];
-    element.threads = modifiedThreads;
-    assert.deepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('show all comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.SHOW_ALL}});
-    flush();
-    assert.equal(getVisibleThreads().length, 9);
-  });
-
-  test('unresolved shows all unresolved comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.UNRESOLVED}});
-    flush();
-    assert.equal(getVisibleThreads().length, 4);
-  });
-
-  test('toggle drafts only shows threads with draft comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.DRAFTS}});
-    flush();
-    assert.equal(getVisibleThreads().length, 2);
-  });
-
-  suite('hideDropdown', () => {
-    setup(async () => {
-      element.hideDropdown = true;
-      await flush();
-    });
-
-    test('toggle buttons are hidden', () => {
-      assert.equal(element.shadowRoot.querySelector('.header').style.display,
-          'none');
-    });
-  });
-
-  suite('empty thread', () => {
-    setup(async () => {
-      element.threads = [];
-      await flush();
-    });
-
-    test('default empty message should show', () => {
-      assert.isTrue(
-          element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
new file mode 100644
index 0000000..f6b9a81
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -0,0 +1,516 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-thread-list';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {
+  compareThreads,
+  GrThreadList,
+  __testOnly_SortDropdownState,
+} from './gr-thread-list';
+import {queryAll} from '../../../test/test-utils';
+import {accountOrGroupKey} from '../../../utils/account-util';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+  createThread,
+} from '../../../test/test-data-generators';
+import {
+  AccountId,
+  NumericChangeId,
+  PatchSetNum,
+  Timestamp,
+} from '../../../api/rest-api';
+import {RobotId, UrlEncodedCommentId} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+  let element: GrThreadList;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    element.changeNum = 123 as NumericChangeId;
+    element.change = createParsedChange();
+    element.account = createAccountDetailWithId();
+    element.threads = [
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000001 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-01 15:15:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            updated: '2015-12-01 15:16:15.000000000' as Timestamp,
+            message: 'draft',
+            unresolved: true,
+            __draft: true,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: 'test.txt',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 3 as PatchSetNum,
+            id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+            updated: '2015-12-02 15:16:15.000000000' as Timestamp,
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3 as PatchSetNum,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2 as PatchSetNum,
+            id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+            updated: '2015-12-03 15:16:15.000000000' as Timestamp,
+            message: 'Another unresolved comment',
+            unresolved: false,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000003 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2 as PatchSetNum,
+            id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+            line: 4,
+            updated: '2015-12-04 15:16:15.000000000' as Timestamp,
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2015-12-05 15:16:15.000000000' as Timestamp,
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_1' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-06 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 1',
+            unresolved: false,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-07 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 2',
+            unresolved: false,
+            patch_set: '3' as PatchSetNum,
+          },
+        ],
+        patchNum: 3 as PatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'rc1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-08 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1' as RobotId,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'rc2' as UrlEncodedCommentId,
+            line: 7,
+            updated: '2015-12-09 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2' as RobotId,
+          },
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'c2_1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-10 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 7,
+        rootId: 'rc2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+    ];
+    await element.updateComplete;
+  });
+
+  suite('sort threads', () => {
+    test('sort all threads', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'patchset_level_2' as UrlEncodedCommentId, // Posted on Patchset 3
+        'patchset_level_1' as UrlEncodedCommentId, // Posted on Patchset 2
+        '8caddf38_44770ec1' as UrlEncodedCommentId, // File level on COMMIT_MSG
+        'scaddf38_44770ec1' as UrlEncodedCommentId, // Line 4 on COMMIT_MSG
+        'rc1' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE newer
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE older
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 6 on COMMIT_MSG
+        'rc2' as UrlEncodedCommentId, // Line 7 on COMMIT_MSG
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId, // File level on test.txt
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+
+    test('sort all threads by timestamp', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'rc2' as UrlEncodedCommentId,
+        'rc1' as UrlEncodedCommentId,
+        'patchset_level_2' as UrlEncodedCommentId,
+        'patchset_level_1' as UrlEncodedCommentId,
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        'scaddf38_44770ec1' as UrlEncodedCommentId,
+        '8caddf38_44770ec1' as UrlEncodedCommentId,
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+      <div class="header">
+        <span class="sort-text">Sort By:</span>
+        <gr-dropdown-list id="sortDropdown"></gr-dropdown-list>
+        <span class="separator"></span>
+        <span class="filter-text">Filter By:</span>
+        <gr-dropdown-list id="filterDropdown"></gr-dropdown-list>
+        <span class="author-text">From:</span>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+        <gr-account-label deselected="" selectionchipstyle=""></gr-account-label>
+      </div>
+      <div id="threads" part="threads">
+        <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread show-file-name="" show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread has-draft="" show-file-name="" show-file-path=""></gr-comment-thread>
+      </div>
+    `);
+  });
+
+  test('renders empty', async () => {
+    element.threads = [];
+    await element.updateComplete;
+    expect(queryAndAssert(element, 'div#threads')).dom.to.equal(`
+      <div id="threads" part="threads">
+        <div><span>No comments</span></div>
+      </div>
+    `);
+  });
+
+  test('tapping single author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+    const authors = chips.map(chip => accountOrGroupKey(chip.account!)).sort();
+    assert.deepEqual(authors, [
+      1 as AccountId,
+      1000000 as AccountId,
+      1000001 as AccountId,
+      1000002 as AccountId,
+      1000003 as AccountId,
+    ]);
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+
+    const chip = chips.find(chip => chip.account!._account_id === 1000001);
+    tap(chip!);
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 1);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+
+    tap(chip!);
+    await element.updateComplete;
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('tapping multiple author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+
+    tap(chips.find(chip => chip.account?._account_id === 1000001)!);
+    tap(chips.find(chip => chip.account?._account_id === 1000002)!);
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 3);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[1].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[2].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+  });
+
+  test('show all comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.SHOW_ALL},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('unresolved shows all unresolved comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.UNRESOLVED},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 4);
+  });
+
+  test('toggle drafts only shows threads with draft comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.DRAFTS},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 2);
+  });
+
+  suite('hideDropdown', () => {
+    test('header hidden for hideDropdown=true', async () => {
+      element.hideDropdown = true;
+      await element.updateComplete;
+      assert.isUndefined(query(element, '.header'));
+    });
+
+    test('header shown for hideDropdown=false', async () => {
+      element.hideDropdown = false;
+      await element.updateComplete;
+      assert.isDefined(query(element, '.header'));
+    });
+  });
+
+  suite('empty thread', () => {
+    setup(async () => {
+      element.threads = [];
+      await element.updateComplete;
+    });
+
+    test('default empty message should show', () => {
+      const threadsEl = queryAndAssert(element, '#threads');
+      assert.isTrue(threadsEl.textContent?.trim().includes('No comments'));
+    });
+  });
+});
+
+suite('compareThreads', () => {
+  let t1: CommentThread;
+  let t2: CommentThread;
+
+  const sortPredicate = (thread1: CommentThread, thread2: CommentThread) =>
+    compareThreads(thread1, thread2);
+
+  const checkOrder = (expected: CommentThread[]) => {
+    assert.sameOrderedMembers([t1, t2].sort(sortPredicate), expected);
+    assert.sameOrderedMembers([t2, t1].sort(sortPredicate), expected);
+  };
+
+  setup(() => {
+    t1 = createThread({});
+    t2 = createThread({});
+  });
+
+  test('patchset-level before file comments', () => {
+    t1.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+    t2.path = SpecialFilePath.COMMIT_MESSAGE;
+    checkOrder([t1, t2]);
+  });
+
+  test('paths lexicographically', () => {
+    t1.path = 'a.txt';
+    t2.path = 'b.txt';
+    checkOrder([t1, t2]);
+  });
+
+  test('patchsets in reverse order', () => {
+    t1.patchNum = 2 as PatchSetNum;
+    t2.patchNum = 3 as PatchSetNum;
+    checkOrder([t2, t1]);
+  });
+
+  test('file level comment before line', () => {
+    t1.line = 123;
+    t2.line = 'FILE';
+    checkOrder([t2, t1]);
+  });
+
+  test('comments sorted by line', () => {
+    t1.line = 123;
+    t2.line = 321;
+    checkOrder([t1, t2]);
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index e82ea89..9392cb9d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -24,6 +24,7 @@
   BasePatchSetNum,
   EditPatchSetNum,
   PatchSetNum,
+  RobotCommentInfo,
   RobotId,
   RobotRunId,
   Timestamp,
@@ -37,7 +38,6 @@
 } from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
-import {UIRobot} from '../../../utils/comment-util';
 import {
   CloseFixPreviewEventDetail,
   EventType,
@@ -50,7 +50,7 @@
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
 
-  const ROBOT_COMMENT_WITH_TWO_FIXES: UIRobot = {
+  const ROBOT_COMMENT_WITH_TWO_FIXES: RobotCommentInfo = {
     id: '1' as UrlEncodedCommentId,
     updated: '2018-02-08 18:49:18.000000000' as Timestamp,
     robot_id: 'robot_1' as RobotId,
@@ -62,7 +62,7 @@
     ],
   };
 
-  const ROBOT_COMMENT_WITH_ONE_FIX: UIRobot = {
+  const ROBOT_COMMENT_WITH_ONE_FIX: RobotCommentInfo = {
     id: '2' as UrlEncodedCommentId,
     updated: '2018-02-08 18:49:18.000000000' as Timestamp,
     robot_id: 'robot_1' as RobotId,
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 32c732e..50399be 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import {
-  CommentBasics,
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
@@ -31,7 +30,6 @@
   CommentThread,
   DraftInfo,
   isUnresolved,
-  UIComment,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
@@ -41,7 +39,7 @@
   addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
@@ -114,7 +112,7 @@
    * patchNum and basePatchNum properties to represent the range.
    */
   getPaths(patchRange?: PatchRange): CommentMap {
-    const responses: {[path: string]: UIComment[]}[] = [
+    const responses: {[path: string]: Comment[]}[] = [
       this._comments,
       this.drafts,
       this._robotComments,
@@ -139,25 +137,11 @@
   }
 
   /**
-   * Gets all the comments for a particular thread group. Used for refreshing
-   * comments after the thread group has already been built.
-   */
-  getCommentsForThread(rootId: UrlEncodedCommentId) {
-    const allThreads = this.getAllThreadsForChange();
-    const threadMatch = allThreads.find(t => t.rootId === rootId);
-
-    // In the event that a single draft comment was removed by the thread-list
-    // and the diff view is updating comments, there will no longer be a thread
-    // found.  In this case, return null.
-    return threadMatch ? threadMatch.comments : null;
-  }
-
-  /**
    * Gets all the comments and robot comments for the given change.
    */
   getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const publishedComments: {[path: string]: CommentBasics[]} = {};
+    const publishedComments: {[path: string]: CommentInfo[]} = {};
     for (const path of Object.keys(paths)) {
       publishedComments[path] = this.getAllCommentsForPath(
         path,
@@ -191,8 +175,8 @@
     path: string,
     patchNum?: PatchSetNum,
     includeDrafts?: boolean
-  ): Comment[] {
-    const comments: Comment[] = this._comments[path] || [];
+  ): CommentInfo[] {
+    const comments: CommentInfo[] = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (includeDrafts) {
@@ -228,43 +212,18 @@
     return allComments;
   }
 
-  cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
-    return new ChangeComments(
-      this._comments,
-      this._robotComments,
-      drafts,
-      this._portedComments,
-      this._portedDrafts
-    );
-  }
-
-  cloneWithUpdatedPortedComments(
-    portedComments?: PathToCommentsInfoMap,
-    portedDrafts?: PathToCommentsInfoMap
-  ) {
-    return new ChangeComments(
-      this._comments,
-      this._robotComments,
-      this._drafts,
-      portedComments,
-      portedDrafts
-    );
-  }
-
   /**
    * Get the drafts for a path and optional patch num.
    *
    * This will return a shallow copy of all drafts every time,
    * so changes on any copy will not affect other copies.
    */
-  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
-    let comments = this._drafts[path] || [];
+  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): DraftInfo[] {
+    let drafts = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => c.patch_set === patchNum);
+      drafts = drafts.filter(c => c.patch_set === patchNum);
     }
-    return comments.map(c => {
-      return {...c, __draft: true};
-    });
+    return drafts;
   }
 
   /**
@@ -272,7 +231,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    */
-  getAllDraftsForFile(file: PatchSetFile): Comment[] {
+  getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
     if (file.basePath) {
       allDrafts = allDrafts.concat(
@@ -292,8 +251,8 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
-    let comments: Comment[] = [];
+  getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
+    let comments: CommentInfo[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
     if (this._comments && this._comments[path]) {
@@ -306,17 +265,13 @@
       robotComments = this._robotComments[path];
     }
 
-    drafts.forEach(d => {
-      d.__draft = true;
-    });
-
-    return comments
-      .concat(drafts)
-      .concat(robotComments)
+    const all = comments.concat(drafts).concat(robotComments);
+    const final = all
       .filter(c => isInPatchRange(c, patchRange))
       .map(c => {
         return {...c};
       });
+    return final;
   }
 
   /**
@@ -367,7 +322,7 @@
     // ported comments will involve comments that may not belong to the
     // current patchrange, so we need to form threads for them using all
     // comments
-    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+    const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
@@ -398,7 +353,6 @@
         return false;
       }
 
-      thread.diffSide = Side.RIGHT;
       if (thread.commentSide === CommentSide.PARENT) {
         // TODO(dhruvsri): Add handling for merge parents
         if (
@@ -406,7 +360,6 @@
           !!thread.mergeParentNum
         )
           return false;
-        thread.diffSide = Side.LEFT;
       }
 
       if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
@@ -423,8 +376,7 @@
     patchRange: PatchRange
   ): CommentThread[] {
     const threads = createCommentThreads(
-      this.getCommentsForFile(file, patchRange),
-      patchRange
+      this.getCommentsForFile(file, patchRange)
     );
     threads.push(...this._getPortedCommentThreads(file, patchRange));
     return threads;
@@ -442,7 +394,10 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+  getCommentsForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentInfo[] {
     const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
       comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -464,11 +419,11 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
+    let comments: CommentInfo[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
     } else {
-      comments = this._commentObjToArray(
+      comments = this._commentObjToArray<CommentInfo>(
         this.getAllPublishedComments(file.patchNum)
       );
     }
@@ -579,8 +534,8 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
-    let drafts: Comment[] = [];
+    let comments: CommentInfo[] = [];
+    let drafts: CommentInfo[] = [];
 
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 7e01371..9770261 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -20,7 +20,7 @@
 import {ChangeComments} from './gr-comment-api.js';
 import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
 import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
-import {CommentSide, Side} from '../../../constants/constants.js';
+import {CommentSide} from '../../../constants/constants.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
@@ -207,7 +207,6 @@
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
         assert.equal(portedThreads.length, 1);
         assert.equal(portedThreads[0].line, 31);
-        assert.equal(portedThreads[0].diffSide, Side.LEFT);
 
         assert.equal(changeComments._getPortedCommentThreads(
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
@@ -363,6 +362,7 @@
           ...createComment(),
           id: '01',
           patch_set: 2,
+          path: 'file/one',
           side: PARENT,
           line: 1,
           updated: makeTime(1),
@@ -379,6 +379,7 @@
           id: '02',
           in_reply_to: '04',
           patch_set: 2,
+          path: 'file/one',
           unresolved: true,
           line: 1,
           updated: makeTime(3),
@@ -388,6 +389,7 @@
           ...createComment(),
           id: '03',
           patch_set: 2,
+          path: 'file/one',
           side: PARENT,
           line: 2,
           updated: makeTime(1),
@@ -397,6 +399,7 @@
           ...createComment(),
           id: '04',
           patch_set: 2,
+          path: 'file/one',
           line: 1,
           updated: makeTime(1),
         };
@@ -470,6 +473,7 @@
           side: PARENT,
           line: 1,
           updated: makeTime(3),
+          path: 'file/one',
         };
 
         commentObjs['13'] = {
@@ -481,6 +485,7 @@
           // Draft gets lower timestamp than published comment, because we
           // want to test that the draft still gets sorted to the end.
           updated: makeTime(2),
+          path: 'file/one',
         };
 
         commentObjs['14'] = {
@@ -597,10 +602,6 @@
         const path = 'file/one';
         const drafts = element._changeComments.getAllDraftsForPath(path);
         assert.equal(drafts.length, 2);
-        const aCopyOfDrafts = element._changeComments
-            .getAllDraftsForPath(path);
-        assert.deepEqual(drafts, aCopyOfDrafts);
-        assert.notEqual(drafts[0], aCopyOfDrafts[0]);
       });
 
       test('computeUnresolvedNum', () => {
@@ -828,24 +829,6 @@
         const threads = element._changeComments.getAllThreadsForChange();
         assert.deepEqual(threads, expectedThreads);
       });
-
-      test('getCommentsForThreadGroup', () => {
-        let expectedComments = [
-          {...commentObjs['04'], path: 'file/one'},
-          {...commentObjs['02'], path: 'file/one'},
-          {...commentObjs['13'], path: 'file/one'},
-        ];
-        assert.deepEqual(element._changeComments.getCommentsForThread('04'),
-            expectedComments);
-
-        expectedComments = [{...commentObjs['12'], path: 'file/one'}];
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('12'),
-            expectedComments);
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
-            null);
-      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index f8fb40c..9c62d1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -25,9 +25,7 @@
 import {
   anyLineTooLong,
   getLine,
-  getRange,
   getSide,
-  rangesEqual,
   SYNTAX_MAX_LINE_LENGTH,
 } from '../gr-diff/gr-diff-utils';
 import {getAppContext} from '../../../services/app-context';
@@ -37,7 +35,11 @@
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {CommentThread} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
+} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
@@ -288,7 +290,7 @@
       // change in some way, and that we should update any models we may want
       // to keep in sync.
       'create-comment',
-      e => this._handleCreateComment(e)
+      e => this._handleCreateThread(e)
     );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
@@ -734,30 +736,29 @@
   }
 
   _threadsChanged(threads: CommentThread[]) {
-    const threadEls = new Set<GrCommentThread>();
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
     for (const threadEl of this.getThreadEls()) {
       if (threadEl.rootId) {
         rootIdToThreadEl.set(threadEl.rootId, threadEl);
       }
     }
+    const dontRemove = new Set<GrCommentThread>();
     for (const thread of threads) {
       const existingThreadEl =
         thread.rootId && rootIdToThreadEl.get(thread.rootId);
       if (existingThreadEl) {
-        this._updateThreadElement(existingThreadEl, thread);
-        threadEls.add(existingThreadEl);
+        existingThreadEl.thread = thread;
+        dontRemove.add(existingThreadEl);
       } else {
         const threadEl = this._createThreadElement(thread);
         this._attachThreadElement(threadEl);
-        threadEls.add(threadEl);
+        dontRemove.add(threadEl);
       }
     }
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
-      if (threadEls.has(threadEl)) continue;
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
+      if (dontRemove.has(threadEl)) continue;
+      threadEl.remove();
     }
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
@@ -785,10 +786,10 @@
     );
   }
 
-  _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+  _handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
     if (!this.patchRange) throw Error('patch range not set');
 
-    const {lineNum, side, range, path} = e.detail;
+    const {lineNum, side, range} = e.detail;
 
     // Usually, the comment is stored on the patchset shown on the side the
     // user added the comment on, and the commentSide will be REVISION.
@@ -806,18 +807,27 @@
         ? CommentSide.PARENT
         : CommentSide.REVISION;
     if (!this.canCommentOnPatchSetNum(patchNum)) return;
-    const threadEl = this._getOrCreateThread({
+    const path =
+      this.file?.basePath &&
+      side === Side.LEFT &&
+      commentSide === CommentSide.REVISION
+        ? this.file?.basePath
+        : this.path;
+    assertIsDefined(path, 'path');
+
+    const newThread: CommentThread = {
+      rootId: undefined,
       comments: [],
-      path,
-      diffSide: side,
-      commentSide,
       patchNum,
+      commentSide,
+      // TODO: Maybe just compute from patchRange.base on the fly?
+      mergeParentNum: this._parentIndex ?? undefined,
+      path,
       line: lineNum,
       range,
-    });
-    threadEl.addOrEditDraft(lineNum, range);
-
-    this.reporting.recordDraftInteraction();
+    };
+    const el = this._createThreadElement(newThread);
+    this._attachThreadElement(el);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
@@ -846,21 +856,6 @@
     return true;
   }
 
-  /**
-   * Gets or creates a comment thread at a given location.
-   * May provide a range, to get/create a range comment.
-   */
-  _getOrCreateThread(thread: CommentThread): GrCommentThread {
-    let threadEl = this._getThreadEl(thread);
-    if (!threadEl) {
-      threadEl = this._createThreadElement(thread);
-      this._attachThreadElement(threadEl);
-    } else {
-      this._updateThreadElement(threadEl, thread);
-    }
-    return threadEl;
-  }
-
   _attachThreadElement(threadEl: Element) {
     this.$.diff.appendChild(threadEl);
   }
@@ -873,67 +868,38 @@
   }
 
   _createThreadElement(thread: CommentThread) {
+    assertIsDefined(this.patchRange, 'patchRange');
+    const commentProps = {
+      patch_set: thread.patchNum,
+      side: thread.commentSide,
+      parent: thread.mergeParentNum,
+    };
+    let diffSide: Side;
+    if (isInBaseOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.LEFT;
+    } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.RIGHT;
+    } else {
+      const propsStr = JSON.stringify(commentProps);
+      const rangeStr = JSON.stringify(this.patchRange);
+      throw new Error(`comment ${propsStr} not in range ${rangeStr}`);
+    }
+
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute(
-      'slot',
-      `${thread.diffSide}-${thread.line || 'LOST'}`
-    );
-    this._updateThreadElement(threadEl, thread);
-    return threadEl;
-  }
-
-  _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
-    threadEl.comments = thread.comments;
-    threadEl.diffSide = thread.diffSide;
-    threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
-    threadEl.parentIndex = this._parentIndex;
-    // Use path before renmaing when comment added on the left when comparing
-    // two patch sets (not against base)
-    if (
-      this.file &&
-      this.file.basePath &&
-      thread.diffSide === Side.LEFT &&
-      !threadEl.isOnParent
-    ) {
-      threadEl.path = this.file.basePath;
-    } else {
-      threadEl.path = this.path;
-    }
-    threadEl.changeNum = this.changeNum;
-    threadEl.patchNum = thread.patchNum;
+    threadEl.rootId = thread.rootId;
+    threadEl.thread = thread;
     threadEl.showPatchset = false;
     threadEl.showPortedComment = !!thread.ported;
-    if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
-    // GrCommentThread does not understand 'FILE', but requires undefined.
-    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
-    threadEl.projectName = this.projectName;
-    threadEl.range = thread.range;
-  }
-
-  /**
-   * Gets a comment thread element at a given location.
-   * May provide a range, to get a range comment.
-   */
-  _getThreadEl(thread: CommentThread): GrCommentThread | null {
-    let line: LineInfo;
-    if (thread.diffSide === Side.LEFT) {
-      line = {beforeNumber: thread.line};
-    } else if (thread.diffSide === Side.RIGHT) {
-      line = {afterNumber: thread.line};
-    } else {
-      throw new Error(`Unknown side: ${thread.diffSide}`);
+    // These attributes are the "interface" between comment threads and gr-diff.
+    // <gr-comment-thread> does not care about them and is not affected by them.
+    threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+    threadEl.setAttribute('diff-side', `${diffSide}`);
+    threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+    if (thread.range) {
+      threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
     }
-    function matchesRange(threadEl: GrCommentThread) {
-      return rangesEqual(getRange(threadEl), thread.range);
-    }
-
-    const filteredThreadEls = this._filterThreadElsForLocation(
-      this.getThreadEls(),
-      line,
-      thread.diffSide
-    ).filter(matchesRange);
-    return filteredThreadEls.length ? filteredThreadEls[0] : null;
+    return threadEl;
   }
 
   _filterThreadElsForLocation(
@@ -1181,8 +1147,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-update': CustomEvent;
-    'comment-save': CustomEvent;
     'root-id-changed': CustomEvent;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index dd15462..6149c82 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -948,7 +948,6 @@
     });
 
     test('creates comments if they do not exist yet', () => {
-      const diffSide = Side.LEFT;
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 2,
@@ -957,7 +956,7 @@
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
           lineNum: 3,
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -966,10 +965,10 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.isTrue(threads[0].isOnParent);
-      assert.equal(threads[0].range, undefined);
-      assert.equal(threads[0].patchNum, 2);
+      assert.equal(threads[0].thread.commentSide, 'PARENT');
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.range, undefined);
+      assert.equal(threads[0].thread.patchNum, 2);
 
       // Try to fetch a thread with a different range.
       const range = {
@@ -986,7 +985,7 @@
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
           lineNum: 1,
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
           range,
         },
@@ -996,10 +995,10 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 2);
-      assert.equal(threads[1].diffSide, diffSide);
-      assert.isTrue(threads[0].isOnParent);
-      assert.equal(threads[1].range, range);
-      assert.equal(threads[1].patchNum, 3);
+      assert.equal(threads[0].thread.commentSide, 'PARENT');
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[1].thread.range, range);
+      assert.equal(threads[1].thread.patchNum, 3);
     });
 
     test('should not be on parent if on the right', () => {
@@ -1014,10 +1013,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isFalse(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'REVISION');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
     });
 
     test('should be on parent if right and base is PARENT', () => {
@@ -1032,10 +1032,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isTrue(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'PARENT');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('should be on parent if right and base negative', () => {
@@ -1050,10 +1051,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isTrue(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'PARENT');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('should not be on parent otherwise', () => {
@@ -1068,24 +1070,25 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isFalse(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'REVISION');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('thread should use old file path if first created ' +
-    'on patch set (left) before renaming', () => {
-      const diffSide = Side.LEFT;
+    'on patch set (left) before renaming', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -1094,22 +1097,22 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.basePath);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.path, element.file.basePath);
     });
 
-    test('thread should use new file path if first created' +
-    'on patch set (right) after renaming', () => {
-      const diffSide = Side.RIGHT;
+    test('thread should use new file path if first created ' +
+    'on patch set (right) after renaming', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.RIGHT,
           path: '/p',
         },
       }));
@@ -1118,23 +1121,27 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+      assert.equal(threads[0].thread.path, element.file.path);
     });
 
-    test('multiple threads created on the same range', () => {
+    test('multiple threads created on the same range', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
-      const comment = createComment();
-      comment.range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 2,
+      const comment = {
+        ...createComment(),
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 2,
+        },
+        patch_set: 3,
       };
       const thread = createCommentThread([comment]);
       element.threads = [thread];
@@ -1159,18 +1166,18 @@
       assert.equal(threads.length, 2);
     });
 
-    test('thread should use new file path if first created' +
-    'on patch set (left) but is base', () => {
-      const diffSide = Side.LEFT;
+    test('thread should use new file path if first created ' +
+    'on patch set (left) but is base', async () => {
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -1179,8 +1186,8 @@
           dom(element.$.diff).queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.path, element.file.path);
     });
 
     test('cannot create thread on an edit', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 7393606..63db013 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -124,7 +124,7 @@
 // For Gerrit these are instances of GrCommentThread, but other gr-diff users
 // have different HTML elements in use for comment threads.
 // TODO: Also document the required HTML attributes that thread elements must
-// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
+// have, e.g. 'diff-side', 'range', 'line-num'.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
 }
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 81be728..e879078 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -211,10 +211,8 @@
     return Promise.all(promises);
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getChangeDetail(changeNum).then(change => {
-      this._change = change;
-    });
+  async _getChangeDetail(changeNum: NumericChangeId) {
+    this._change = await this.restApiService.getChangeDetail(changeNum);
   }
 
   _editChange(value?: ParsedChangeInfo | null) {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index f276051..07f3851 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -16,6 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma';
+import './gr-editor-view';
 import {GrEditorView} from './gr-editor-view';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {HttpMethod} from '../../../constants/constants';
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
new file mode 100644
index 0000000..50a2782
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Binding} from '../../utils/dom-util';
+import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {getAppContext} from '../../services/app-context';
+
+interface ShortcutListener {
+  binding: Binding;
+  listener: (e: KeyboardEvent) => void;
+}
+
+type Cleanup = () => void;
+
+export class ShortcutController implements ReactiveController {
+  private readonly service: ShortcutsService = getAppContext().shortcutsService;
+
+  private readonly listenersLocal: ShortcutListener[] = [];
+
+  private readonly listenersGlobal: ShortcutListener[] = [];
+
+  private cleanups: Cleanup[] = [];
+
+  constructor(private readonly host: ReactiveControllerHost & HTMLElement) {
+    host.addController(this);
+  }
+
+  // Note that local shortcuts are *not* suppressed when the user has shortcuts
+  // disabled or when the event comes from elements like <input>. So this method
+  // is intended for shortcuts like ESC and Ctrl-ENTER.
+  // If you need suppressed local shortcuts, then just add an options parameter.
+  addLocal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersLocal.push({binding, listener});
+  }
+
+  addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersGlobal.push({binding, listener});
+  }
+
+  hostConnected() {
+    for (const {binding, listener} of this.listenersLocal) {
+      const cleanup = this.service.addShortcut(this.host, binding, listener, {
+        shouldSuppress: false,
+      });
+      this.cleanups.push(cleanup);
+    }
+    for (const {binding, listener} of this.listenersGlobal) {
+      const cleanup = this.service.addShortcut(
+        document.body,
+        binding,
+        listener
+      );
+      this.cleanups.push(cleanup);
+    }
+  }
+
+  hostDisconnected() {
+    for (const cleanup of this.cleanups) {
+      cleanup();
+    }
+    this.cleanups = [];
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 9b11bc6..1e99b7f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -19,265 +19,635 @@
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-thread_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, queryAll, state} from 'lit/decorators';
 import {
   computeDiffFromContext,
-  computeId,
-  DraftInfo,
   isDraft,
   isRobot,
-  sortComments,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  Comment,
+  CommentThread,
+  getLastComment,
+  UnsavedInfo,
+  isDraftOrUnsaved,
+  createUnsavedComment,
+  getFirstComment,
+  createUnsavedReply,
+  isUnsaved,
 } from '../../../utils/comment-util';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
 import {
-  CommentSide,
   createDefaultDiffPrefs,
-  Side,
   SpecialFilePath,
 } from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   CommentRange,
-  ConfigInfo,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {
-  assertIsDefined,
-  check,
-  queryAndAssert,
-} from '../../../utils/common-util';
-import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire, fireAlert, waitForEventOnce} from '../../../utils/event-util';
 import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
-import {StorageLocation} from '../../../services/storage/gr-storage';
 import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
 import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addGlobalShortcut} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {repeat} from 'lit/directives/repeat';
+import {classMap} from 'lit/directives/class-map';
+import {changeNum$, repo$} from '../../../services/change/change-model';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
 
-const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
 
-export interface GrCommentThread {
-  $: {
-    replyBtn: GrButton;
-    quoteBtn: GrButton;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    'comment-thread-editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
+/**
+ * gr-comment-thread exposes the following attributes that allow a
+ * diff widget like gr-diff to show the thread in the right location:
+ *
+ * line-num:
+ *     1-based line number or 'FILE' if it refers to the entire file.
+ *
+ * diff-side:
+ *     "left" or "right". These indicate which of the two diffed versions
+ *     the comment relates to. In the case of unified diff, the left
+ *     version is the one whose line number column is further to the left.
+ *
+ * range:
+ *     The range of text that the comment refers to (start_line,
+ *     start_character, end_line, end_character), serialized as JSON. If
+ *     set, range's end_line will have the same value as line-num. Line
+ *     numbers are 1-based, char numbers are 0-based. The start position
+ *     (start_line, start_character) is inclusive, and the end position
+ *     (end_line, end_character) is exclusive.
+ */
 @customElement('gr-comment-thread')
-export class GrCommentThread extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCommentThread extends LitElement {
+  @query('#replyBtn')
+  replyBtn?: GrButton;
+
+  @query('#quoteBtn')
+  quoteBtn?: GrButton;
+
+  @query('.comment-box')
+  commentBox?: HTMLElement;
+
+  @queryAll('gr-comment')
+  commentElements?: NodeList;
+
+  /** Required to be set by parent. */
+  @property()
+  thread?: CommentThread;
 
   /**
-   * gr-comment-thread exposes the following attributes that allow a
-   * diff widget like gr-diff to show the thread in the right location:
+   * Id of the first comment and thus must not change. Will be derived from
+   * the `thread` property in the first willUpdate() cycle.
    *
-   * line-num:
-   *     1-based line number or 'FILE' if it refers to the entire file.
+   * The `rootId` property is also used in gr-diff for maintaining lists and
+   * maps of threads and their associated elements.
    *
-   * diff-side:
-   *     "left" or "right". These indicate which of the two diffed versions
-   *     the comment relates to. In the case of unified diff, the left
-   *     version is the one whose line number column is further to the left.
-   *
-   * range:
-   *     The range of text that the comment refers to (start_line,
-   *     start_character, end_line, end_character), serialized as JSON. If
-   *     set, range's end_line will have the same value as line-num. Line
-   *     numbers are 1-based, char numbers are 0-based. The start position
-   *     (start_line, start_character) is inclusive, and the end position
-   *     (end_line, end_character) is exclusive.
+   * Only stays `undefined` for new threads that only have an unsaved comment.
    */
-  @property({type: Number})
-  changeNum?: NumericChangeId;
-
-  @property({type: Array})
-  comments: UIComment[] = [];
-
-  @property({type: Object, reflectToAttribute: true})
-  range?: CommentRange;
-
-  @property({type: String, reflectToAttribute: true})
-  diffSide?: Side;
-
   @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: String})
-  path: string | undefined;
-
-  @property({type: String, observer: '_projectNameChanged'})
-  projectName?: RepoName;
-
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  hasDraft?: boolean;
-
-  @property({type: Boolean})
-  isOnParent = false;
-
-  @property({type: Number})
-  parentIndex: number | null = null;
-
-  @property({
-    type: String,
-    notify: true,
-    computed: '_computeRootId(comments.*)',
-  })
   rootId?: UrlEncodedCommentId;
 
-  @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+  // TODO: Is this attribute needed for querySelector() or css rules?
+  // We don't need this internally for the component.
+  @property({type: Boolean, reflect: true, attribute: 'has-draft'})
+  hasDraft?: boolean;
+
+  /** Will be inspected on firstUpdated() only. */
+  @property({type: Boolean, attribute: 'should-scroll-into-view'})
   shouldScrollIntoView = false;
 
-  @property({type: Boolean})
+  /**
+   * Should the file path and line number be rendered above the comment thread
+   * widget? Typically true in <gr-thread-list> and false in <gr-diff>.
+   */
+  @property({type: Boolean, attribute: 'show-file-path'})
   showFilePath = false;
 
-  @property({type: Object, reflectToAttribute: true})
-  lineNum?: LineNumber;
+  /**
+   * Only relevant when `showFilePath` is set.
+   * If false, then only the line number is rendered.
+   */
+  @property({type: Boolean, attribute: 'show-file-name'})
+  showFileName = false;
 
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  unresolved?: boolean;
+  @property({type: Boolean, attribute: 'show-ported-comment'})
+  showPortedComment = false;
 
-  @property({type: Boolean})
-  _showActions?: boolean;
+  /** This is set to false by <gr-diff>. */
+  @property({type: Boolean, attribute: false})
+  showPatchset = true;
 
-  @property({type: Object})
-  _lastComment?: UIComment;
+  @property({type: Boolean, attribute: 'show-comment-context'})
+  showCommentContext = false;
 
-  @property({type: Array})
-  _orderedComments: UIComment[] = [];
+  /**
+   * Optional context information when a thread is being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  /**
+   * We are reflecting the editing state of the draft comment here. This is not
+   * an input property, but can be inspected from the parent component.
+   *
+   * Changes to this property are fired as 'comment-thread-editing-changed'
+   * events.
+   */
+  @property({type: Boolean, attribute: 'false'})
+  editing = false;
 
-  @property({type: Object})
-  _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+  /**
+   * This can either be an unsaved reply to the last comment or the unsaved
+   * content of a brand new comment thread (then `comments` is empty).
+   * If set, then `thread.comments` must not contain a draft. A thread can only
+   * contain *either* an unsaved comment *or* a draft, not both.
+   */
+  @state()
+  unsavedComment?: UnsavedInfo;
 
-  @property({type: Object})
-  _renderPrefs: RenderPreferences = {
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @state()
+  renderPrefs: RenderPreferences = {
     hide_left_side: true,
     disable_context_control_buttons: true,
     show_file_comment_button: false,
     hide_line_length_indicator: true,
   };
 
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
+  @state()
+  repoName?: RepoName;
 
-  @property({type: Boolean})
-  showFileName = true;
+  @state()
+  account?: AccountDetailInfo;
 
-  @property({type: Boolean})
-  showPortedComment = false;
-
-  @property({type: Boolean})
-  showPatchset = true;
-
-  @property({type: Boolean})
-  showCommentContext = false;
-
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  @property({type: Object, computed: 'computeDiff(comments, path)'})
-  _diff?: DiffInfo;
+  /** Computed during willUpdate(). */
+  @state()
+  diff?: DiffInfo;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  /** Computed during willUpdate(). */
+  @state()
+  highlightRange?: CommentRange;
 
-  private readonly reporting = getAppContext().reportingService;
+  /**
+   * Reflects the *dirty* state of whether the thread is currently unresolved.
+   * We are listening on the <gr-comment> of the draft, so we even know when the
+   * checkbox is checked, even if not yet saved.
+   */
+  @state()
+  unresolved = true;
+
+  /**
+   * Normally drafts are saved within the <gr-comment> child component and we
+   * don't care about that. But when creating 'Done.' replies we are actually
+   * saving from this component. True while the REST API call is inflight.
+   */
+  @state()
+  saving = false;
 
   private readonly commentsService = getAppContext().commentsService;
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly userModel = getAppContext().userModel;
 
-  private readonly shortcuts = getAppContext().shortcutsService;
-
-  readonly storage = getAppContext().storageService;
+  private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
   constructor() {
     super();
-    this.addEventListener('comment-update', e =>
-      this._handleCommentUpdate(e as CustomEvent)
+    subscribe(this, changeNum$, x => (this.changeNum = x));
+    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(this, repo$, x => (this.repoName = x));
+    subscribe(this, this.userModel.diffPreferences$, x =>
+      this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
     );
-    this.restApiService.getPreferences().then(prefs => {
-      this._initLayers(!!prefs?.disable_token_highlighting);
+    subscribe(this, this.userModel.preferences$, prefs => {
+      const layers: DiffLayer[] = [this.syntaxLayer];
+      if (!prefs.disable_token_highlighting) {
+        layers.push(new TokenHighlightLayer(this));
+      }
+      this.layers = layers;
     });
-  }
-
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.cleanups.push(
-      addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e), {
-        doNotPrevent: true,
-        shouldSuppress: true,
-      })
-    );
-    this.cleanups.push(
-      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e), {
-        doNotPrevent: true,
-        shouldSuppress: true,
-      })
-    );
-    this._getLoggedIn().then(loggedIn => {
-      this._showActions = loggedIn;
-    });
-    this.restApiService.getDiffPreferences().then(prefs => {
-      if (!prefs) return;
-      this._prefs = {
+    subscribe(this, this.userModel.diffPreferences$, prefs => {
+      this.prefs = {
         ...prefs,
         // set line_wrapping to true so that the context can take all the
         // remaining space after comment card has rendered
         line_wrapping: true,
       };
-      this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    this._setInitialExpandedState();
+    this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+    this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
   }
 
-  computeDiff(comments?: UIComment[], path?: string) {
-    if (comments === undefined || path === undefined) return undefined;
-    if (!comments[0]?.context_lines?.length) return undefined;
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          font-family: var(--font-family);
+          font-size: var(--font-size-normal);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-normal);
+          /* Explicitly set the background color of the diff. We
+           * cannot use the diff content type ab because of the skip chunk preceding
+           * it, diff processor assumes the chunk of type skip/ab can be collapsed
+           * and hides our diff behind context control buttons.
+           *  */
+          --dark-add-highlight-color: var(--background-color-primary);
+        }
+        gr-button {
+          margin-left: var(--spacing-m);
+        }
+        gr-comment {
+          border-bottom: 1px solid var(--comment-separator-color);
+        }
+        #actions {
+          margin-left: auto;
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        .comment-box {
+          width: 80ch;
+          max-width: 100%;
+          background-color: var(--comment-background-color);
+          color: var(--comment-text-color);
+          box-shadow: var(--elevation-level-2);
+          border-radius: var(--border-radius);
+          flex-shrink: 0;
+        }
+        #container {
+          display: var(--gr-comment-thread-display, flex);
+          align-items: flex-start;
+          margin: 0 var(--spacing-s) var(--spacing-s);
+          white-space: normal;
+          /** This is required for firefox to continue the inheritance */
+          -webkit-user-select: inherit;
+          -moz-user-select: inherit;
+          -ms-user-select: inherit;
+          user-select: inherit;
+        }
+        .comment-box.unresolved {
+          background-color: var(--unresolved-comment-background-color);
+        }
+        .comment-box.robotComment {
+          background-color: var(--robot-comment-background-color);
+        }
+        #actionsContainer {
+          display: flex;
+        }
+        .comment-box.saving #actionsContainer {
+          opacity: 0.5;
+        }
+        #unresolvedLabel {
+          font-family: var(--font-family);
+          margin: auto 0;
+          padding: var(--spacing-m);
+        }
+        .pathInfo {
+          display: flex;
+          align-items: baseline;
+          justify-content: space-between;
+          padding: 0 var(--spacing-s) var(--spacing-s);
+        }
+        .fileName {
+          padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+        }
+        @media only screen and (max-width: 1200px) {
+          .diff-container {
+            display: none;
+          }
+        }
+        .diff-container {
+          margin-left: var(--spacing-l);
+          border: 1px solid var(--border-color);
+          flex-grow: 1;
+          flex-shrink: 1;
+          max-width: 1200px;
+        }
+        .view-diff-button {
+          margin: var(--spacing-s) var(--spacing-m);
+        }
+        .view-diff-container {
+          border-top: 1px solid var(--border-color);
+          background-color: var(--background-color-primary);
+        }
+
+        /* In saved state the "reply" and "quote" buttons are 28px height.
+         * top:4px  positions the 20px icon vertically centered.
+         * Currently in draft state the "save" and "cancel" buttons are 20px
+         * height, so the link icon does not need a top:4px in gr-comment_html.
+         */
+        .link-icon {
+          position: relative;
+          top: 4px;
+          cursor: pointer;
+        }
+        .fileName gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: top;
+          --gr-button-padding: 0px;
+        }
+        .fileName:focus-within gr-copy-clipboard,
+        .fileName:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.thread) return;
+    const dynamicBoxClasses = {
+      robotComment: this.isRobotComment(),
+      unresolved: this.unresolved,
+      saving: this.saving,
+    };
+    return html`
+      ${this.renderFilePath()}
+      <div id="container">
+        <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
+        <div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
+          ${this.renderComments()} ${this.renderActions()}
+        </div>
+        ${this.renderContextualDiff()}
+      </div>
+    `;
+  }
+
+  renderFilePath() {
+    if (!this.showFilePath) return;
+    const href = this.getUrlForComment();
+    const line = this.computeDisplayLine();
+    return html`
+      ${this.renderFileName()}
+      <div class="pathInfo">
+        ${href
+          ? html`<a href="${href}">${line}</a>`
+          : html`<span>${line}</span>`}
+      </div>
+    `;
+  }
+
+  renderFileName() {
+    if (!this.showFileName) return;
+    if (this.isPatchsetLevel()) {
+      return html`<div class="fileName"><span>Patchset</span></div>`;
+    }
+    const href = this.getDiffUrlForPath();
+    const displayPath = this.getDisplayPath();
+    return html`
+      <div class="fileName">
+        ${href
+          ? html`<a href="${href}">${displayPath}</a>`
+          : html`<span>${displayPath}</span>`}
+        <gr-copy-clipboard hideInput .text="${displayPath}"></gr-copy-clipboard>
+      </div>
+    `;
+  }
+
+  renderComments() {
+    assertIsDefined(this.thread, 'thread');
+    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const comments: Comment[] = [...this.thread.comments];
+    if (this.unsavedComment && !this.isDraft()) {
+      comments.push(this.unsavedComment);
+    }
+    return repeat(
+      comments,
+      // We want to reuse <gr-comment> when unsaved changes to draft.
+      comment => (isDraftOrUnsaved(comment) ? 'unsaved' : comment.id),
+      comment => {
+        const initiallyCollapsed =
+          !isDraftOrUnsaved(comment) &&
+          (this.messageId
+            ? comment.change_message_id !== this.messageId
+            : !this.unresolved);
+        return html`
+          <gr-comment
+            .comment="${comment}"
+            .comments="${this.thread!.comments}"
+            .patchNum="${this.thread?.patchNum}"
+            ?initially-collapsed="${initiallyCollapsed}"
+            ?robot-button-disabled="${robotButtonDisabled}"
+            ?show-patchset="${this.showPatchset}"
+            ?show-ported-comment="${this.showPortedComment &&
+            comment.id === this.rootId}"
+            @create-fix-comment="${this.handleCommentFix}"
+            @copy-comment-link="${this.handleCopyLink}"
+            @comment-editing-changed="${(e: CustomEvent) => {
+              if (isDraftOrUnsaved(comment)) this.editing = e.detail;
+            }}"
+            @comment-unresolved-changed="${(e: CustomEvent) => {
+              if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
+            }}"
+          ></gr-comment>
+        `;
+      }
+    );
+  }
+
+  renderActions() {
+    if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
+      return;
+    return html`
+      <div id="actionsContainer">
+        <span id="unresolvedLabel">${
+          this.unresolved ? 'Unresolved' : 'Resolved'
+        }</span>
+        <div id="actions">
+          <iron-icon
+              class="link-icon copy"
+              @click="${this.handleCopyLink}"
+              title="Copy link to this comment"
+              icon="gr-icons:link"
+              role="button"
+              tabindex="0"
+          >
+          </iron-icon>
+          <gr-button
+              id="replyBtn"
+              link
+              class="action reply"
+              ?disabled="${this.saving}"
+              @click="${() => this.handleCommentReply(false)}"
+          >Reply</gr-button
+          >
+          <gr-button
+              id="quoteBtn"
+              link
+              class="action quote"
+              ?disabled="${this.saving}"
+              @click="${() => this.handleCommentReply(true)}"
+          >Quote</gr-button
+          >
+          ${
+            this.unresolved
+              ? html`
+                  <gr-button
+                    id="ackBtn"
+                    link
+                    class="action ack"
+                    ?disabled="${this.saving}"
+                    @click="${this.handleCommentAck}"
+                    >Ack</gr-button
+                  >
+                  <gr-button
+                    id="doneBtn"
+                    link
+                    class="action done"
+                    ?disabled="${this.saving}"
+                    @click="${this.handleCommentDone}"
+                    >Done</gr-button
+                  >
+                `
+              : ''
+          }
+        </div>
+      </div>
+      </div>
+    `;
+  }
+
+  renderContextualDiff() {
+    if (!this.changeNum || !this.showCommentContext || !this.diff) return;
+    if (!this.thread?.path) return;
+    const href = this.getUrlForComment();
+    return html`
+      <div class="diff-container">
+        <gr-diff
+          id="diff"
+          .changeNum="${this.changeNum}"
+          .diff="${this.diff}"
+          .layers="${this.layers}"
+          .path="${this.thread.path}"
+          .prefs="${this.prefs}"
+          .renderPrefs="${this.renderPrefs}"
+          .highlightRange="${this.highlightRange}"
+        >
+        </gr-diff>
+        <div class="view-diff-container">
+          <a href="${href}">
+            <gr-button link class="view-diff-button">View Diff</gr-button>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (!this.thread) return;
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    if (this.getFirstComment() === undefined) {
+      this.unsavedComment = createUnsavedComment(this.thread);
+    }
+    this.unresolved = this.getLastComment()?.unresolved ?? true;
+    this.diff = this.computeDiff();
+    this.highlightRange = this.computeHighlightRange();
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('thread')) {
+      if (!this.isDraftOrUnsaved()) {
+        // We can only do this for threads without draft, because otherwise we
+        // are relying on the <gr-comment> component for the draft to fire
+        // events about the *dirty* `unresolved` state.
+        this.unresolved = this.getLastComment()?.unresolved ?? true;
+      }
+      this.hasDraft = this.isDraftOrUnsaved();
+      this.rootId = this.getFirstComment()?.id;
+      if (this.isDraft()) {
+        this.unsavedComment = undefined;
+      }
+    }
+    if (changed.has('editing')) {
+      if (!this.editing) {
+        this.unsavedComment = undefined;
+        if (this.thread?.comments.length === 0) {
+          this.remove();
+        }
+      }
+      fire(this, 'comment-thread-editing-changed', {value: this.editing});
+    }
+  }
+
+  override firstUpdated() {
+    if (this.shouldScrollIntoView) {
+      this.commentBox?.focus();
+      this.scrollIntoView();
+    }
+  }
+
+  private isDraft() {
+    return isDraft(this.getLastComment());
+  }
+
+  private isDraftOrUnsaved(): boolean {
+    return this.isDraft() || this.isUnsaved();
+  }
+
+  private isNewThread(): boolean {
+    return this.thread?.comments.length === 0;
+  }
+
+  private isUnsaved(): boolean {
+    return !!this.unsavedComment || this.thread?.comments.length === 0;
+  }
+
+  private isPatchsetLevel() {
+    return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  }
+
+  private computeDiff() {
+    if (!this.showCommentContext) return;
+    if (!this.thread?.path) return;
+    const firstComment = this.getFirstComment();
+    if (!firstComment?.context_lines?.length) return;
     const diff = computeDiffFromContext(
-      comments[0].context_lines,
-      path,
-      comments[0].source_content_type
+      firstComment.context_lines,
+      this.thread?.path,
+      firstComment.source_content_type
     );
     // Do we really have to re-compute (and re-render) the diff?
-    if (this._diff && JSON.stringify(this._diff) === JSON.stringify(diff)) {
-      return this._diff;
+    if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
+      return this.diff;
     }
 
     if (!anyLineTooLong(diff)) {
@@ -289,83 +659,21 @@
     return diff;
   }
 
-  handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
-    // Wait for comment to be rendered before scrolling to it
-    if (shouldScrollIntoView) {
-      const resizeObserver = new ResizeObserver(
-        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
-          if (this.offsetHeight > 0) {
-            queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
-            this.scrollIntoView();
-          }
-          observer.unobserve(this);
-        }
-      );
-      resizeObserver.observe(this);
+  private getDiffUrlForPath() {
+    if (!this.changeNum || !this.repoName || !this.thread?.path) {
+      return undefined;
     }
+    if (this.isNewThread()) return undefined;
+    return GerritNav.getUrlForDiffById(
+      this.changeNum,
+      this.repoName,
+      this.thread.path,
+      this.thread.patchNum
+    );
   }
 
-  _shouldShowCommentContext(
-    changeNum?: NumericChangeId,
-    showCommentContext?: boolean,
-    diff?: DiffInfo
-  ) {
-    return changeNum && showCommentContext && !!diff;
-  }
-
-  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
-    const lastComment = this.comments[this.comments.length - 1] || {};
-    if (isDraft(lastComment)) {
-      const commentEl = this._commentElWithDraftID(
-        lastComment.id || lastComment.__draftID
-      );
-      if (!commentEl) throw new Error('Failed to find draft.');
-      commentEl.editing = true;
-
-      // If the comment was collapsed, re-open it to make it clear which
-      // actions are available.
-      commentEl.collapsed = false;
-    } else {
-      const range = rangeParam
-        ? rangeParam
-        : lastComment
-        ? lastComment.range
-        : undefined;
-      const unresolved = lastComment ? lastComment.unresolved : undefined;
-      this.addDraft(lineNum, range, unresolved);
-    }
-  }
-
-  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
-    const draft = this._newDraft(lineNum, range);
-    draft.__editing = true;
-    draft.unresolved = unresolved === false ? unresolved : true;
-    this.commentsService.addDraft(draft);
-  }
-
-  _getDiffUrlForPath(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!changeNum || !projectName || !path) return undefined;
-    if (isDraft(this.comments[0])) {
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  /** The parameter is for triggering re-computation only. */
-  getHighlightRange(_: unknown) {
-    const comment = this.comments?.[0];
+  private computeHighlightRange() {
+    const comment = this.getFirstComment();
     if (!comment) return undefined;
     if (comment.range) return comment.range;
     if (comment.line) {
@@ -379,413 +687,130 @@
     return undefined;
   }
 
-  _initLayers(disableTokenHighlighting: boolean) {
-    if (!disableTokenHighlighting) {
-      this.layers.push(new TokenHighlightLayer(this));
+  private getUrlForComment() {
+    if (!this.repoName || !this.changeNum || this.isNewThread()) {
+      return undefined;
     }
-    this.layers.push(this.syntaxLayer);
-  }
-
-  _getUrlForViewDiff(
-    comments: UIComment[],
-    changeNum?: NumericChangeId,
-    projectName?: RepoName
-  ): string {
-    if (!changeNum) return '';
-    if (!projectName) return '';
-    check(comments.length > 0, 'comment not found');
-    return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
-  }
-
-  _getDiffUrlForComment(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!projectName || !changeNum || !path) return undefined;
-    if (
-      (this.comments.length && this.comments[0].side === 'PARENT') ||
-      isDraft(this.comments[0])
-    ) {
-      if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum,
-        undefined,
-        this.lineNum === FILE ? undefined : this.lineNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  handleCopyLink() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.projectName, 'projectName');
-    const url = generateAbsoluteUrl(
-      GerritNav.getUrlForCommentsTab(
-        this.changeNum,
-        this.projectName,
-        this.comments[0].id!
-      )
+    assertIsDefined(this.rootId, 'rootId of comment thread');
+    return GerritNav.getUrlForComment(
+      this.changeNum,
+      this.repoName,
+      this.rootId
     );
-    navigator.clipboard.writeText(url).then(() => {
+  }
+
+  private handleCopyLink() {
+    const url = this.getUrlForComment();
+    assertIsDefined(url, 'url for comment');
+    navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
       fireAlert(this, 'Link copied to clipboard');
     });
   }
 
-  _isPatchsetLevelComment(path?: string) {
-    return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  private getDisplayPath() {
+    if (this.isPatchsetLevel()) return 'Patchset';
+    return computeDisplayPath(this.thread?.path);
   }
 
-  _computeShowPortedComment(comment: UIComment) {
-    if (this._orderedComments.length === 0) return false;
-    return this.showPortedComment && comment.id === this._orderedComments[0].id;
-  }
-
-  _computeDisplayPath(path?: string) {
-    const displayPath = computeDisplayPath(path);
-    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return 'Patchset';
-    }
-    return displayPath;
-  }
-
-  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
-    if (lineNum === FILE) {
-      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return '';
-      }
-      return FILE;
-    }
-    if (lineNum) return `#${lineNum}`;
+  private computeDisplayLine() {
+    assertIsDefined(this.thread, 'thread');
+    if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
+    if (this.thread.line) return `#${this.thread.line}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (range) return `#${range.end_line}`;
+    if (this.thread.range) return `#${this.thread.range.end_line}`;
     return '';
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  private isRobotComment() {
+    return isRobot(this.getLastComment());
   }
 
-  _getUnresolvedLabel(unresolved?: boolean) {
-    return unresolved ? 'Unresolved' : 'Resolved';
+  private getFirstComment() {
+    assertIsDefined(this.thread);
+    return getFirstComment(this.thread);
   }
 
-  @observe('comments.*')
-  _commentsChanged() {
-    this._orderedComments = sortComments(this.comments);
-    this.updateThreadProperties();
+  private getLastComment() {
+    assertIsDefined(this.thread);
+    return getLastComment(this.thread);
   }
 
-  updateThreadProperties() {
-    if (this._orderedComments.length) {
-      this._lastComment = this._getLastComment();
-      this.unresolved = this._lastComment.unresolved;
-      this.hasDraft = isDraft(this._lastComment);
-      this.isRobotComment = isRobot(this._lastComment);
+  private handleExpandShortcut() {
+    this.expandCollapseComments(false);
+  }
+
+  private handleCollapseShortcut() {
+    this.expandCollapseComments(true);
+  }
+
+  private expandCollapseComments(actionIsCollapse: boolean) {
+    for (const comment of this.commentElements ?? []) {
+      (comment as GrComment).collapsed = actionIsCollapse;
     }
   }
 
-  _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
-    return !_showActions || !_lastComment || isDraft(_lastComment);
-  }
-
-  _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
-    return (
-      this._shouldDisableAction(_showActions, _lastComment) ||
-      isRobot(_lastComment)
-    );
-  }
-
-  _getLastComment() {
-    return this._orderedComments[this._orderedComments.length - 1] || {};
-  }
-
-  private handleExpandShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(false);
-  }
-
-  private handleCollapseShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(true);
-  }
-
-  _expandCollapseComments(actionIsCollapse: boolean) {
-    const comments = this.root?.querySelectorAll('gr-comment');
-    if (!comments) return;
-    for (const comment of comments) {
-      comment.collapsed = actionIsCollapse;
+  private async createReplyComment(
+    content: string,
+    userWantsToEdit: boolean,
+    unresolved: boolean
+  ) {
+    const replyingTo = this.getLastComment();
+    assertIsDefined(this.thread, 'thread');
+    assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
+    if (isDraft(replyingTo)) {
+      throw new Error('cannot reply to draft');
     }
-  }
-
-  /**
-   * Sets the initial state of the comment thread.
-   * Expands the thread if one of the following is true:
-   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-   * thread is unresolved,
-   * - it's a robot comment.
-   * - it's a draft
-   */
-  _setInitialExpandedState() {
-    if (this._orderedComments) {
-      for (let i = 0; i < this._orderedComments.length; i++) {
-        const comment = this._orderedComments[i];
-        if (isDraft(comment)) {
-          comment.collapsed = false;
-          continue;
-        }
-        const isRobotComment = !!(comment as UIRobot).robot_id;
-        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-        const resolvedThread =
-          !this.unresolved ||
-          this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
-        if (comment.collapsed === undefined) {
-          comment.collapsed = !isRobotComment && resolvedThread;
-        }
+    if (isUnsaved(replyingTo)) {
+      throw new Error('cannot reply to unsaved comment');
+    }
+    const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+    if (userWantsToEdit) {
+      this.unsavedComment = unsaved;
+    } else {
+      try {
+        this.saving = true;
+        await this.commentsService.saveDraft(unsaved);
+      } finally {
+        this.saving = false;
       }
     }
   }
 
-  _createReplyComment(
-    content?: string,
-    isEditing?: boolean,
-    unresolved?: boolean
-  ) {
-    this.reporting.recordDraftInteraction();
-    const id = this._orderedComments[this._orderedComments.length - 1].id;
-    if (!id) throw new Error('Cannot reply to comment without id.');
-    const reply = this._newReply(id, content, unresolved);
-
-    if (isEditing) {
-      reply.__editing = true;
-      this.commentsService.addDraft(reply);
-    } else {
-      assertIsDefined(this.changeNum, 'changeNum');
-      assertIsDefined(this.patchNum, 'patchNum');
-      this.restApiService
-        .saveDiffDraft(this.changeNum, this.patchNum, reply)
-        .then(result => {
-          if (!result.ok) {
-            fireAlert(document, 'Unable to restore draft');
-            return;
-          }
-          this.restApiService.getResponseObject(result).then(obj => {
-            const resComment = obj as unknown as DraftInfo;
-            resComment.patch_set = reply.patch_set;
-            this.commentsService.addDraft(resComment);
-          });
-        });
-    }
-  }
-
-  _isDraft(comment: UIComment) {
-    return isDraft(comment);
-  }
-
-  _processCommentReply(quote?: boolean) {
-    const comment = this._lastComment;
+  private handleCommentReply(quote: boolean) {
+    const comment = this.getLastComment();
     if (!comment) throw new Error('Failed to find last comment.');
-    let content = undefined;
+    let content = '';
     if (quote) {
       const msg = comment.message;
       if (!msg) throw new Error('Quoting empty comment.');
       content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
     }
-    this._createReplyComment(content, true, comment.unresolved);
+    this.createReplyComment(content, true, comment.unresolved ?? true);
   }
 
-  _handleCommentReply() {
-    this._processCommentReply();
+  private handleCommentAck() {
+    this.createReplyComment('Ack', false, false);
   }
 
-  _handleCommentQuote() {
-    this._processCommentReply(true);
+  private handleCommentDone() {
+    this.createReplyComment('Done', false, false);
   }
 
-  _handleCommentAck() {
-    this._createReplyComment('Ack', false, false);
-  }
-
-  _handleCommentDone() {
-    this._createReplyComment('Done', false, false);
-  }
-
-  _handleCommentFix(e: CustomEvent) {
+  private handleCommentFix(e: CustomEvent) {
     const comment = e.detail.comment;
     const msg = comment.message;
     const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
     const quoteStr = '> ' + quoted + '\n\n';
     const response = quoteStr + 'Please fix.';
-    this._createReplyComment(response, false, true);
+    this.createReplyComment(response, false, true);
   }
 
-  _commentElWithDraftID(id?: string): GrComment | null {
-    if (!id) return null;
-    const els = this.root?.querySelectorAll('gr-comment');
-    if (!els) return null;
-    for (const el of els) {
-      const c = el.comment;
-      if (isRobot(c)) continue;
-      if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
-    }
-    return null;
-  }
-
-  _newReply(
-    inReplyTo: UrlEncodedCommentId,
-    message?: string,
-    unresolved?: boolean
-  ) {
-    const d = this._newDraft();
-    d.in_reply_to = inReplyTo;
-    if (message !== undefined) {
-      d.message = message;
-    }
-    if (unresolved !== undefined) {
-      d.unresolved = unresolved;
-    }
-    return d;
-  }
-
-  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
-    const d: UIDraft = {
-      __draft: true,
-      __draftID: 'draft__' + Math.random().toString(36),
-      __date: new Date(),
-    };
-    if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
-    // For replies, always use same meta info as root.
-    if (this.comments && this.comments.length >= 1) {
-      const rootComment = this.comments[0];
-      if (rootComment.path !== undefined) d.path = rootComment.path;
-      if (rootComment.patch_set !== undefined)
-        d.patch_set = rootComment.patch_set;
-      if (rootComment.side !== undefined) d.side = rootComment.side;
-      if (rootComment.line !== undefined) d.line = rootComment.line;
-      if (rootComment.range !== undefined) d.range = rootComment.range;
-      if (rootComment.parent !== undefined) d.parent = rootComment.parent;
-    } else {
-      // Set meta info for root comment.
-      d.path = this.path;
-      d.patch_set = this.patchNum;
-      d.side = this._getSide(this.isOnParent);
-
-      if (lineNum && lineNum !== FILE) {
-        d.line = lineNum;
-      }
-      if (range) {
-        d.range = range;
-      }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-    }
-    return d;
-  }
-
-  _getSide(isOnParent: boolean): CommentSide {
-    return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
-  }
-
-  _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
-    // Keep the root ID even if the comment was removed, so that notification
-    // to sync will know which thread to remove.
-    if (!comments.base.length) {
-      return this.rootId;
-    }
-    return computeId(comments.base[0]);
-  }
-
-  _handleCommentDiscard() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.patchNum, 'patchNum');
-    // Check to see if there are any other open comments getting edited and
-    // set the local storage value to its message value.
-    for (const changeComment of this.comments) {
-      if (isDraft(changeComment) && changeComment.__editing) {
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum: this.patchNum,
-          path: changeComment.path,
-          line: changeComment.line,
-        };
-        this.storage.setDraftComment(
-          commentLocation,
-          changeComment.message ?? ''
-        );
-      }
-    }
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const index = this._indexOf(comment, this.comments);
-    if (index === -1) {
-      // This should never happen: comment belongs to another thread.
-      this.reporting.error(
-        new Error(`Comment update for another comment thread: ${comment}`)
-      );
-      return;
-    }
-    this.set(['comments', index], comment);
-    // Because of the way we pass these comment objects around by-ref, in
-    // combination with the fact that Polymer does dirty checking in
-    // observers, the this.set() call above will not cause a thread update in
-    // some situations.
-    this.updateThreadProperties();
-  }
-
-  _indexOf(comment: UIComment | undefined, arr: UIComment[]) {
-    if (!comment) return -1;
-    for (let i = 0; i < arr.length; i++) {
-      const c = arr[i];
-      if (
-        (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
-        (c.id && c.id === comment.id)
-      ) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /** 2nd parameter is for triggering re-computation only. */
-  _computeHostClass(unresolved?: boolean, _?: unknown) {
-    if (this.isRobotComment) {
-      return 'robotComment';
-    }
-    return unresolved ? 'unresolved' : '';
-  }
-
-  /**
-   * Load the project config when a project name has been provided.
-   *
-   * @param name The project name.
-   */
-  _projectNameChanged(name?: RepoName) {
-    if (!name) {
-      return;
-    }
-    this.restApiService.getProjectConfig(name).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _computeAriaHeading(_orderedComments: UIComment[]) {
-    const firstComment = _orderedComments[0];
-    const author = firstComment?.author ?? this._selfAccount;
-    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
-    const status = [
-      lastComment.unresolved ? 'Unresolved' : '',
-      isDraft(lastComment) ? 'Draft' : '',
-    ].join(' ');
-    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  private computeAriaHeading() {
+    const author = this.getFirstComment()?.author ?? this.account;
+    const user = getUserName(undefined, author);
+    const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
+    const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+    return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
deleted file mode 100644
index c3faaa5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      font-family: var(--font-family);
-      font-size: var(--font-size-normal);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-normal);
-      /* Explicitly set the background color of the diff. We
-       * cannot use the diff content type ab because of the skip chunk preceding
-       * it, diff processor assumes the chunk of type skip/ab can be collapsed
-       * and hides our diff behind context control buttons.
-       *  */
-      --dark-add-highlight-color: var(--background-color-primary);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    gr-comment {
-      border-bottom: 1px solid var(--comment-separator-color);
-    }
-    #actions {
-      margin-left: auto;
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    .comment-box {
-      width: 80ch;
-      max-width: 100%;
-      background-color: var(--comment-background-color);
-      color: var(--comment-text-color);
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      flex-shrink: 0;
-    }
-    #container {
-      display: var(--gr-comment-thread-display, flex);
-      align-items: flex-start;
-      margin: 0 var(--spacing-s) var(--spacing-s);
-      white-space: normal;
-      /** This is required for firefox to continue the inheritance */
-      -webkit-user-select: inherit;
-      -moz-user-select: inherit;
-      -ms-user-select: inherit;
-      user-select: inherit;
-    }
-    .comment-box.unresolved {
-      background-color: var(--unresolved-comment-background-color);
-    }
-    .comment-box.robotComment {
-      background-color: var(--robot-comment-background-color);
-    }
-    #commentInfoContainer {
-      display: flex;
-    }
-    #unresolvedLabel {
-      font-family: var(--font-family);
-      margin: auto 0;
-      padding: var(--spacing-m);
-    }
-    .pathInfo {
-      display: flex;
-      align-items: baseline;
-      justify-content: space-between;
-      padding: 0 var(--spacing-s) var(--spacing-s);
-    }
-    .fileName {
-      padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
-    }
-    @media only screen and (max-width: 1200px) {
-      .diff-container {
-        display: none;
-      }
-    }
-    .diff-container {
-      margin-left: var(--spacing-l);
-      border: 1px solid var(--border-color);
-      flex-grow: 1;
-      flex-shrink: 1;
-      max-width: 1200px;
-    }
-    .view-diff-button {
-      margin: var(--spacing-s) var(--spacing-m);
-    }
-    .view-diff-container {
-      border-top: 1px solid var(--border-color);
-      background-color: var(--background-color-primary);
-    }
-
-    /* In saved state the "reply" and "quote" buttons are 28px height.
-     * top:4px  positions the 20px icon vertically centered.
-     * Currently in draft state the "save" and "cancel" buttons are 20px
-     * height, so the link icon does not need a top:4px in gr-comment_html.
-     */
-    .link-icon {
-      position: relative;
-      top: 4px;
-      cursor: pointer;
-    }
-    .fileName gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: top;
-      --gr-button-padding: 0px;
-    }
-    .fileName:focus-within gr-copy-clipboard,
-    .fileName:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-  </style>
-
-  <template is="dom-if" if="[[showFilePath]]">
-    <template is="dom-if" if="[[showFileName]]">
-      <div class="fileName">
-        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
-          <span> [[_computeDisplayPath(path)]] </span>
-        </template>
-        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-          <a
-            href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
-          >
-            [[_computeDisplayPath(path)]]
-          </a>
-          <gr-copy-clipboard
-            hideInput=""
-            text="[[_computeDisplayPath(path)]]"
-          ></gr-copy-clipboard>
-        </template>
-      </div>
-    </template>
-    <div class="pathInfo">
-      <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-        <a
-          href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine(lineNum, range)]]</a
-        >
-      </template>
-    </div>
-  </template>
-  <div id="container">
-    <h3 class="assistive-tech-only">
-      [[_computeAriaHeading(_orderedComments)]]
-    </h3>
-    <div
-      class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
-      tabindex="0"
-    >
-      <template
-        id="commentList"
-        is="dom-repeat"
-        items="[[_orderedComments]]"
-        as="comment"
-      >
-        <gr-comment
-          comment="{{comment}}"
-          comments="{{comments}}"
-          robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-          change-num="[[changeNum]]"
-          project-name="[[projectName]]"
-          patch-num="[[patchNum]]"
-          draft="[[_isDraft(comment)]]"
-          show-actions="[[_showActions]]"
-          show-patchset="[[showPatchset]]"
-          show-ported-comment="[[_computeShowPortedComment(comment)]]"
-          side="[[comment.side]]"
-          project-config="[[_projectConfig]]"
-          on-create-fix-comment="_handleCommentFix"
-          on-comment-discard="_handleCommentDiscard"
-          on-copy-comment-link="handleCopyLink"
-        ></gr-comment>
-      </template>
-      <div
-        id="commentInfoContainer"
-        hidden$="[[_hideActions(_showActions, _lastComment)]]"
-      >
-        <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
-        <div id="actions">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-          <gr-button
-            id="replyBtn"
-            link=""
-            class="action reply"
-            on-click="_handleCommentReply"
-            >Reply</gr-button
-          >
-          <gr-button
-            id="quoteBtn"
-            link=""
-            class="action quote"
-            on-click="_handleCommentQuote"
-            >Quote</gr-button
-          >
-          <template is="dom-if" if="[[unresolved]]">
-            <gr-button
-              id="ackBtn"
-              link=""
-              class="action ack"
-              on-click="_handleCommentAck"
-              >Ack</gr-button
-            >
-            <gr-button
-              id="doneBtn"
-              link=""
-              class="action done"
-              on-click="_handleCommentDone"
-              >Done</gr-button
-            >
-          </template>
-        </div>
-      </div>
-    </div>
-    <template
-      is="dom-if"
-      if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
-    >
-      <div class="diff-container">
-        <gr-diff
-          id="diff"
-          change-num="[[changeNum]]"
-          diff="[[_diff]]"
-          layers="[[layers]]"
-          path="[[path]]"
-          prefs="[[_prefs]]"
-          render-prefs="[[_renderPrefs]]"
-          highlight-range="[[getHighlightRange(comments)]]"
-        >
-        </gr-diff>
-        <div class="view-diff-container">
-          <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
-            <gr-button link class="view-diff-button">View Diff</gr-button>
-          </a>
-        </div>
-      </div>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index a4664ee..ab08996 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -14,1005 +14,334 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-comment-thread';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {SpecialFilePath, Side} from '../../../constants/constants';
-import {
-  sortComments,
-  UIComment,
-  UIRobot,
-  UIDraft,
-} from '../../../utils/comment-util';
+import {sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
 import {
-  PatchSetNum,
   NumericChangeId,
   UrlEncodedCommentId,
   Timestamp,
-  RobotId,
-  RobotRunId,
+  CommentInfo,
   RepoName,
-  ConfigInfo,
-  EmailAddress,
 } from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
-import {LineNumber} from '../../diff/gr-diff/gr-diff-line';
-import {
-  tap,
-  pressAndReleaseKeyOn,
-} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   mockPromise,
+  queryAndAssert,
   stubComments,
-  stubReporting,
   stubRestApi,
+  waitUntilCalled,
+  MockPromise,
 } from '../../../test/test-utils';
-import {createComment} from '../../../test/test-data-generators';
+import {
+  createAccountDetailWithId,
+  createThread,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonStub} from 'sinon';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
+const c1 = {
+  author: {name: 'Kermit'},
+  id: 'the-root' as UrlEncodedCommentId,
+  message: 'start the conversation',
+  updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c2 = {
+  author: {name: 'Ms Piggy'},
+  id: 'the-reply' as UrlEncodedCommentId,
+  message: 'keep it going',
+  updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3 = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'stop it',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-reply' as UrlEncodedCommentId,
+  __draft: true,
+};
+
+const commentWithContext = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'just for context',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  line: 5,
+  context_lines: [
+    {line_number: 4, context_line: 'content of line 4'},
+    {line_number: 5, context_line: 'content of line 5'},
+    {line_number: 6, context_line: 'content of line 6'},
+  ],
+};
 
 suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element: GrCommentThread;
+  let element: GrCommentThread;
 
-    setup(async () => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      element.patchNum = 3 as PatchSetNum;
-      element.changeNum = 1 as NumericChangeId;
-      await flush();
-    });
+  setup(async () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+    element.changeNum = 1 as NumericChangeId;
+    element.showFileName = true;
+    element.showFilePath = true;
+    element.repoName = 'test-repo-name' as RepoName;
+    await element.updateComplete;
+    element.account = {...createAccountDetailWithId(13), name: 'Yoda'};
+  });
 
-    test('renders', async () => {
-      element.comments = [
-        {
-          ...createComment(),
-          author: {name: 'Kermit'},
-          id: 'the-root' as UrlEncodedCommentId,
-          message: 'start the conversation',
-          updated: '2021-11-01 10:11:12.000000000' as Timestamp,
-        },
-        {
-          ...createComment(),
-          author: {name: 'Ms Piggy'},
-          id: 'the-reply' as UrlEncodedCommentId,
-          message: 'keep it going',
-          updated: '2021-11-02 10:11:12.000000000' as Timestamp,
-          in_reply_to: 'the-root' as UrlEncodedCommentId,
-        },
-        {
-          ...createComment(),
-          author: {name: 'Kermit'},
-          id: 'the-draft' as UrlEncodedCommentId,
-          message: 'stop it',
-          updated: '2021-11-03 10:11:12.000000000' as Timestamp,
-          in_reply_to: 'the-reply' as UrlEncodedCommentId,
-          __draft: true,
-        },
-      ];
-      await flush();
-      expect(element).shadowDom.to.equal(`
-        <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+  });
+
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+        <div class="fileName">
+          <span>test-path-comment-thread</span>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+        </div>
+        <div class="pathInfo">
+          <span>#314</span>
+        </div>
         <div id="container">
           <h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
           <div class="comment-box" tabindex="0">
-            <gr-comment></gr-comment>
-            <gr-comment></gr-comment>
-            <gr-comment></gr-comment>
-            <dom-repeat as="comment" id="commentList" style="display: none;">
-              <template is="dom-repeat"></template>
-            </dom-repeat>
-            <div hidden="true" id="commentInfoContainer">
-              <span id="unresolvedLabel">Resolved</span>
+            <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment collapsed="" initially-collapsed="" robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+          </div>
+        </div>
+      `);
+  });
+
+  test('renders unsaved', async () => {
+    element.thread = createThread();
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+        <div class="fileName">
+          <span>test-path-comment-thread</span>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+        </div>
+        <div class="pathInfo">
+          <span>#314</span>
+        </div>
+        <div id="container">
+          <h3 class="assistive-tech-only">Unresolved Draft Comment thread by Yoda</h3>
+          <div class="comment-box unresolved" tabindex="0">
+            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+          </div>
+        </div>
+      `);
+  });
+
+  test('renders with actions resolved', async () => {
+    element.thread = createThread(c1, c2);
+    await element.updateComplete;
+    expect(queryAndAssert(element, '#container')).dom.to.equal(`
+        <div id="container">
+          <h3 class="assistive-tech-only">Comment thread by Kermit</h3>
+          <div class="comment-box" tabindex="0">
+            <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+            <gr-comment collapsed="" initially-collapsed="" show-patchset=""></gr-comment>
+            <div id="actionsContainer">
+              <span id="unresolvedLabel">
+                Resolved
+              </span>
               <div id="actions">
-                <iron-icon
-                  class="link-icon"
-                  icon="gr-icons:link"
-                  role="button"
-                  tabindex="0"
-                  title="Copy link to this comment"
-                >
+                <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
                 </iron-icon>
-                <gr-button
-                  aria-disabled="false"
-                  class="action reply"
-                  id="replyBtn"
-                  link=""
-                  role="button"
-                  tabindex="0"
-                >
+                <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
                   Reply
                 </gr-button>
-                <gr-button
-                  aria-disabled="false"
-                  class="action quote"
-                  id="quoteBtn"
-                  link=""
-                  role="button"
-                  tabindex="0"
-                >
+                <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
                   Quote
                 </gr-button>
-                <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
               </div>
             </div>
           </div>
-          <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
         </div>
       `);
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments: UIComment[] = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          __date: new Date('2015-12-25'),
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_mission' as UrlEncodedCommentId,
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-      ];
-      const results = sortComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_mission' as UrlEncodedCommentId,
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          message: 'i like you, too' as UrlEncodedCommentId,
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [
-        {
-          id: 'jacks_reply' as UrlEncodedCommentId,
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
-          __draft: true,
-        },
-      ];
-      const commentElStub = sinon
-        .stub(element, '_commentElWithDraftID')
-        .callsFake(() => new GrComment());
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sinon
-        .stub(element, '_commentElWithDraftID')
-        .callsFake(() => new GrComment());
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment: UIComment = {};
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        false
-      );
-      showActions = false;
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        true
-      );
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        true
-      );
-      const robotComment: UIRobot = {
-        id: '1234' as UrlEncodedCommentId,
-        updated: '1234' as Timestamp,
-        robot_id: 'robot_id' as RobotId,
-        robot_run_id: 'robot_run_id' as RobotRunId,
-        properties: {},
-        fix_suggestions: [],
-      };
-      assert.equal(
-        element._shouldDisableAction(showActions, robotComment),
-        false
-      );
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment: UIComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment: UIRobot = {
-        id: '1234' as UrlEncodedCommentId,
-        updated: '1234' as Timestamp,
-        robot_id: 'robot_id' as RobotId,
-        robot_run_id: 'robot_run_id' as RobotRunId,
-        properties: {},
-        fix_suggestions: [],
-      };
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', async () => {
-      const projectName = 'foo/bar/baz' as RepoName;
-      const getProjectStub = stubRestApi('getProjectConfig').returns(
-        Promise.resolve({} as ConfigInfo)
-      );
-      element.projectName = projectName;
-      await flush();
-      assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
-
-      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
-      element.changeNum = 123 as NumericChangeId;
-      element.projectName = 'test project' as RepoName;
-      element.path = 'path/to/file';
-      element.patchNum = 3 as PatchSetNum;
-      element.lineNum = 5;
-      element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
-      element.showFilePath = true;
-      flush();
-      assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
-      assert.notEqual(
-        getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
-          .display,
-        'none'
-      );
-      assert.isTrue(
-        commentStub.calledWithExactly(
-          element.changeNum,
-          element.projectName,
-          'comment_id' as UrlEncodedCommentId
-        )
-      );
-    });
-
-    test('_computeDisplayPath', () => {
-      let path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.patchNum = 3 as PatchSetNum;
-      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayPath(path), 'Patchset');
-    });
-
-    test('_computeDisplayLine', () => {
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        ''
-      );
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element: GrCommentThread;
-  let addDraftServiceStub: SinonStub;
-  let saveDiffDraftStub: SinonStub;
-  let comment = {
-    id: '7afa4931_de3d65bd',
-    path: '/path/to/file.txt',
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    updated: '2015-12-21 02:01:10.850000000',
-    message: 'Done',
-  };
-  const peanutButterComment = {
-    author: {
-      name: 'Mr. Peanutbutter',
-      email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-    },
-    id: 'baf0414d_60047215' as UrlEncodedCommentId,
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    message: 'is this a crossover episode!?',
-    updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-    path: '/path/to/file.txt',
-    unresolved: true,
-    patch_set: 3 as PatchSetNum,
-  };
-  const mockResponse: Response = {
-    ...new Response(),
-    headers: {} as Headers,
-    redirected: false,
-    status: 200,
-    statusText: '',
-    type: '' as ResponseType,
-    url: '',
-    ok: true,
-    text() {
-      return Promise.resolve(")]}'\n" + JSON.stringify(comment));
-    },
-  };
-  let saveDiffDraftPromiseResolver: (value?: Response) => void;
-  setup(() => {
-    addDraftServiceStub = stubComments('addDraft');
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
-      new Promise<Response>(
-        resolve =>
-          (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
-      )
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
-    element.changeNum = 1 as NumericChangeId;
-    element.comments = [peanutButterComment];
-    flush();
   });
 
-  test('reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    tap(replyBtn);
-    flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.isOk(draft);
-    assert.notOk(draft.message, 'message should be empty');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
+  test('renders with actions unresolved', async () => {
+    element.thread = createThread(c1, {...c2, unresolved: true});
+    await element.updateComplete;
+    expect(queryAndAssert(element, '#container')).dom.to.equal(`
+        <div id="container">
+          <h3 class="assistive-tech-only">Unresolved Comment thread by Kermit</h3>
+          <div class="comment-box unresolved" tabindex="0">
+            <gr-comment show-patchset=""></gr-comment>
+            <gr-comment show-patchset=""></gr-comment>
+            <div id="actionsContainer">
+              <span id="unresolvedLabel">
+                Unresolved
+              </span>
+              <div id="actions">
+                <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0" title="Copy link to this comment">
+                </iron-icon>
+                <gr-button aria-disabled="false" class="action reply" id="replyBtn" link="" role="button" tabindex="0">
+                  Reply
+                </gr-button>
+                <gr-button aria-disabled="false" class="action quote" id="quoteBtn" link="" role="button" tabindex="0">
+                  Quote
+                </gr-button>
+                <gr-button aria-disabled="false" class="action ack" id="ackBtn" link="" role="button" tabindex="0">
+                  Ack
+                </gr-button>
+                <gr-button aria-disabled="false" class="action done" id="doneBtn" link="" role="button" tabindex="0">
+                  Done
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
   });
 
-  test('quote reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // the quote reply is not autmatically saved so verify that id is not set
-    assert.isNotOk(draft.id);
-    // verify that the draft returned was not saved
-    assert.isNotOk(saveDiffDraftStub.called);
-    assert.equal(draft.message, '> is this a crossover episode!?\n\n');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
+  test('renders with diff', async () => {
+    element.showCommentContext = true;
+    element.thread = createThread(commentWithContext);
+    await element.updateComplete;
+    expect(queryAndAssert(element, '.diff-container')).dom.to.equal(`
+        <div class="diff-container">
+          <gr-diff
+            class="disable-context-control-buttons hide-line-length-indicator no-left"
+            id="diff"
+            style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
+          >
+          </gr-diff>
+          <div class="view-diff-container">
+            <a href="">
+              <gr-button aria-disabled="false" class="view-diff-button" link="" role="button" tabindex="0">
+                View Diff
+              </gr-button>
+            </a>
+          </div>
+        </div>
+      `);
   });
 
-  test('quote reply multiline', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.comments = [
+  suite('action button clicks', () => {
+    let savePromise: MockPromise<void>;
+    let stub: SinonStub;
+
+    setup(async () => {
+      savePromise = mockPromise<void>();
+      stub = stubComments('saveDraft').returns(savePromise);
+
+      element.thread = createThread(c1, {...c2, unresolved: true});
+      await element.updateComplete;
+    });
+
+    test('handle Ack', async () => {
+      tap(queryAndAssert(element, '#ackBtn'));
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Ack');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+      assert.isFalse(element.saving);
+    });
+
+    test('handle Done', async () => {
+      tap(queryAndAssert(element, '#doneBtn'));
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Done');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+    });
+
+    test('handle Reply', async () => {
+      assert.isUndefined(element.unsavedComment);
+      tap(queryAndAssert(element, '#replyBtn'));
+      assert.equal(element.unsavedComment?.message, '');
+    });
+
+    test('handle Quote', async () => {
+      assert.isUndefined(element.unsavedComment);
+      tap(queryAndAssert(element, '#quoteBtn'));
+      assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
+    });
+  });
+
+  test('comments are sorted correctly', () => {
+    const comments: CommentInfo[] = [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        path: 'test',
-        line: 5,
-        message: 'is this a crossover episode!?\nIt might be!',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i’m running away',
+        updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_defiance' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i will poison you so i can get away',
+        updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'no i will pull a thread and your arm will fall off',
+        updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_mission' as UrlEncodedCommentId,
+        message: 'i have to find santa',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
       },
     ];
-    flush();
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(
-      draft.message,
-      '> is this a crossover episode!?\n> It might be!\n\n'
-    );
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Ack',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    assert.isOk(ackBtn);
-    tap(ackBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(draft.message, 'Ack');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('done', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Done',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.isFalse(saveDiffDraftStub.called);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.isOk(doneBtn);
-    tap(doneBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // Since the reply is automatically saved, verify that draft.id is set in
-    // the model
-    assert.equal(draft.id, '7afa4931_de3d65bd');
-    assert.equal(draft.message, 'Done');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-    assert.isTrue(saveDiffDraftStub.called);
-  });
-
-  test('save', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
-
-    await flush();
-    assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-  });
-
-  test('please fix', async () => {
-    comment = peanutButterComment;
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-    const promise = mockPromise();
-    commentEl!.addEventListener('create-fix-comment', async () => {
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isFalse(addDraftServiceStub.called);
-      saveDiffDraftPromiseResolver(mockResponse);
-      // flushing so the saveDiffDraftStub resolves and the draft is returned
-      await flush();
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isTrue(addDraftServiceStub.called);
-      const draft = saveDiffDraftStub.firstCall.args[2];
-      assert.equal(
-        draft.message,
-        '> is this a crossover episode!?\n\nPlease fix.'
-      );
-      assert.equal(
-        draft.in_reply_to,
-        'baf0414d_60047215' as UrlEncodedCommentId
-      );
-      assert.isTrue(draft.unresolved);
-      promise.resolve();
-    });
-    assert.isFalse(saveDiffDraftStub.called);
-    assert.isFalse(addDraftServiceStub.called);
-    commentEl!.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        detail: {comment: commentEl!.comment},
-        composed: true,
-        bubbles: false,
-      })
-    );
-    await promise;
-  });
-
-  test('discard', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    assert.isOk(element.comments[0]);
-    const deleteDraftStub = stubComments('deleteDraft');
-    element.push(
-      'comments',
-      element._newReply(
-        element.comments[0]!.id as UrlEncodedCommentId,
-        'it’s pronouced jiff, not giff'
-      )
-    );
-    await flush();
-
-    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl?._fireSave(); // tell the model about the draft
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-  });
-
-  test('discard with a single comment still fires event with previous rootId', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    element.comments = [];
-    element.addOrEditDraft(1 as LineNumber);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    const rootId = element.rootId;
-    assert.isOk(rootId);
-    flush();
-    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
-    assert.ok(draftEl);
-    const deleteDraftStub = stubComments('deleteDraft');
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-    assert.isTrue(deleteDraftStub.called);
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    assert.isOk(commentEl);
-    commentEl!.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: {comment: updatedComment},
-        composed: true,
-        bubbles: true,
-      })
-    );
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply' as UrlEncodedCommentId,
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-          unresolved: false,
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-      ];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
-      pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      pressAndReleaseKeyOn(element, 69, 'shift', 'E');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment('dummy', true);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      flush();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(
-        element._orderedComments[4].in_reply_to,
-        'jacks_reply' as UrlEncodedCommentId
-      );
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment('dummy', true, true);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      flush();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    let draft;
-    element.comments = [];
-    element.path = 'abcd';
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-  });
-
-  test('_newDraft with root', () => {
-    const draft = element._newDraft();
-    assert.equal(draft.patch_set, 3 as PatchSetNum);
-  });
-
-  test('_newDraft with no root', () => {
-    element.comments = [];
-    element.diffSide = Side.RIGHT;
-    element.patchNum = 2 as PatchSetNum;
-    const draft = element._newDraft();
-    assert.equal(draft.patch_set, 2 as PatchSetNum);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.path = 'abcd';
-    element.addOrEditDraft(1);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = (element.comments[0] as UIDraft)
-      .__draftID as UrlEncodedCommentId;
-    delete (element.comments[0] as UIDraft).__draft;
-    element.addOrEditDraft(1);
-    assert.equal(addDraftServiceStub.callCount, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    const label = element.shadowRoot?.querySelector('#unresolvedLabel');
-    assert.isOk(label);
-    assert.isFalse(label!.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(label!.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [
+    const results = sortComments(comments);
+    assert.deepEqual(results, [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '2' as UrlEncodedCommentId,
-        line: 5,
-        message: 'Earlier draft',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-        __draft: true,
+        id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i’m running away',
+        updated: '2015-10-31 09:00:20.396000000' as Timestamp,
       },
       {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '1' as UrlEncodedCommentId,
-        line: 5,
-        message: 'This comment was left last but is not a draft',
-        updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+        id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'no i will pull a thread and your arm will fall off',
+        updated: '2015-10-31 11:00:20.396000000' as Timestamp,
       },
       {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '3' as UrlEncodedCommentId,
-        line: 5,
-        message: 'Later draft',
-        updated: '2015-12-09 19:48:33.843000000' as Timestamp,
-        __draft: true,
+        id: 'sallys_defiance' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i will poison you so i can get away',
+        updated: '2015-10-31 15:00:20.396000000' as Timestamp,
       },
-    ];
-    assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
-    assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
-    assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.diffSide = Side.LEFT;
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('diff-side'), Side.LEFT);
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.isOk(element.getAttribute('range'));
-    assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    });
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
-  let element: GrCommentThread;
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('saveDiffDraft').returns(
-      Promise.resolve({
-        ...new Response(),
-        ok: true,
-        text() {
-          return Promise.resolve(
-            ")]}'\n" +
-              JSON.stringify({
-                id: '7afa4931_de3d65bd',
-                path: '/path/to/file.txt',
-                line: 5,
-                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-                updated: '2015-12-21 02:01:10.850000000',
-                message: 'Done',
-              })
-          );
-        },
-      })
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
-    element.changeNum = 1 as NumericChangeId;
-    element.comments = [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-        path: '/path/to/file.txt',
-        unresolved: false,
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
       },
-    ];
-    flush();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
+      {
+        id: 'sallys_mission' as UrlEncodedCommentId,
+        message: 'i have to find santa',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+    ]);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 0c58c33..76eb132 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -27,51 +27,46 @@
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment_html';
-import {getRootElement} from '../../../scripts/rootElement';
 import {getAppContext} from '../../../services/app-context';
-import {customElement, observe, property} from '@polymer/decorators';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
-  BasePatchSetNum,
-  ConfigInfo,
+  CommentLinks,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
+  RobotCommentInfo,
 } from '../../../types/common';
-import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
-  isDraft,
+  Comment,
+  DraftInfo,
+  isDraftOrUnsaved,
   isRobot,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  isUnsaved,
+  UnsavedInfo,
 } from '../../../utils/comment-util';
-import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
-import {pluralize} from '../../../utils/string-util';
+import {
+  OpenFixPreviewEventDetail,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {classMap} from 'lit/directives/class-map';
+import {changeNum$, repo$} from '../../../services/change/change-model';
+import {LineNumber} from '../../../api/diff';
+import {CommentSide} from '../../../constants/constants';
+import {getRandomInt} from '../../../utils/math-util';
 
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
 const FILE = 'FILE';
 
 export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
@@ -88,25 +83,21 @@
   'When disagreeing, explain the advantage of your approach.',
 ];
 
-interface CommentOverlays {
-  confirmDelete?: GrOverlay | null;
-  confirmDiscard?: GrOverlay | null;
+declare global {
+  interface HTMLElementEventMap {
+    'comment-editing-changed': CustomEvent<boolean>;
+    'comment-unresolved-changed': CustomEvent<boolean>;
+    'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+  }
 }
 
-export interface GrComment {
-  $: {
-    container: HTMLDivElement;
-    resolvedCheckbox: HTMLInputElement;
-    header: HTMLDivElement;
-  };
+export interface CommentAnchorTapEventDetail {
+  number: LineNumber;
+  side?: CommentSide;
 }
 
 @customElement('gr-comment')
-export class GrComment extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrComment extends LitElement {
   /**
    * Fired when the create fix comment action is triggered.
    *
@@ -120,30 +111,6 @@
    */
 
   /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * Fired when this comment is edited.
-   *
-   * @event comment-edit
-   */
-
-  /**
-   * Fired when this comment is saved.
-   *
-   * @event comment-save
-   */
-
-  /**
-   * Fired when this comment is updated.
-   *
-   * @event comment-update
-   */
-
-  /**
    * Fired when editing status changed.
    *
    * @event comment-editing-changed
@@ -155,124 +122,93 @@
    * @event comment-anchor-tap
    */
 
-  @property({type: Number})
-  changeNum?: NumericChangeId;
+  @query('#editTextarea')
+  textarea?: GrTextarea;
 
-  @property({type: String})
-  projectName?: RepoName;
+  @query('#container')
+  container?: HTMLElement;
 
-  @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment?: UIComment;
+  @query('#resolvedCheckbox')
+  resolvedCheckbox?: HTMLInputElement;
 
+  @query('#confirmDeleteOverlay')
+  confirmDeleteOverlay?: GrOverlay;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property. This is only used for hasHumanReply at the moment.
   @property({type: Array})
-  comments?: UIComment[];
-
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
-
-  @property({type: Boolean, observer: '_draftChanged'})
-  draft = false;
-
-  @property({type: Boolean, observer: '_editingChanged'})
-  editing = false;
-
-  // Assigns a css property to the comment hiding the comment while it's being
-  // discarded
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-  })
-  discarding = false;
-
-  @property({type: Boolean})
-  hasChildren?: boolean;
-
-  @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: Boolean})
-  showActions?: boolean;
-
-  @property({type: Boolean})
-  _showHumanActions?: boolean;
-
-  @property({type: Boolean})
-  _showRobotActions?: boolean;
-
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    observer: '_toggleCollapseClass',
-  })
-  collapsed = true;
-
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
-  @property({type: Boolean})
-  robotButtonDisabled = false;
-
-  @property({type: Boolean})
-  _hasHumanReply?: boolean;
-
-  @property({type: Boolean})
-  _isAdmin = false;
-
-  @property({type: Object})
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  _xhrPromise?: Promise<any>; // Used for testing.
-
-  @property({type: String, observer: '_messageTextChanged'})
-  _messageText = '';
-
-  @property({type: String})
-  side?: string;
-
-  @property({type: Boolean})
-  resolved = false;
-
-  // Intentional to share the object across instances.
-  @property({type: Object})
-  _numPendingDraftRequests: {number: number} = {number: 0};
-
-  @property({type: Boolean})
-  _enableOverlay = false;
+  comments?: Comment[];
 
   /**
-   * Property for storing references to overlay elements. When the overlays
-   * are moved to getRootElement() to be shown they are no-longer
-   * children, so they can't be queried along the tree, so they are stored
-   * here.
+   * Initial collapsed state of the comment.
    */
-  @property({type: Object})
-  _overlays: CommentOverlays = {};
+  @property({type: Boolean, attribute: 'initially-collapsed'})
+  initiallyCollapsed?: boolean;
+
+  /**
+   * This is the *current* (internal) collapsed state of the comment. Do not set
+   * from the outside. Use `initiallyCollapsed` instead. This is just a
+   * reflected property such that css rules can be based on it.
+   */
+  @property({type: Boolean, reflect: true})
+  collapsed?: boolean;
+
+  @property({type: Boolean, attribute: 'robot-button-disabled'})
+  robotButtonDisabled = false;
+
+  /* internal only, but used in css rules */
+  @property({type: Boolean, reflect: true})
+  saving = false;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  editing = false;
+
+  @state()
+  commentLinks: CommentLinks = {};
+
+  @state()
+  repoName?: RepoName;
+
+  /* The 'dirty' state of the comment.message, which will be saved on demand. */
+  @state()
+  messageText = '';
+
+  /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
+  @state()
+  unresolved = true;
 
   @property({type: Boolean})
-  _showRespectfulTip = false;
+  showConfirmDeleteOverlay = false;
 
   @property({type: Boolean})
-  showPatchset = true;
+  showRespectfulTip = false;
 
   @property({type: String})
-  _respectfulReviewTip?: string;
+  respectfulReviewTip?: string;
 
   @property({type: Boolean})
-  _respectfulTipDismissed = false;
+  respectfulTipDismissed = false;
 
   @property({type: Boolean})
-  _unableToSave = false;
+  unableToSave = false;
 
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
+  @property({type: Boolean, attribute: 'show-patchset'})
+  showPatchset = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-ported-comment'})
   showPortedComment = false;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  isAdmin = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -282,65 +218,671 @@
 
   private readonly commentsService = getAppContext().commentsService;
 
-  private fireUpdateTask?: DelayedTask;
+  private readonly userModel = getAppContext().userModel;
 
-  private storeTask?: DelayedTask;
+  private readonly configModel = getAppContext().configModel;
 
-  private draftToastTask?: DelayedTask;
+  private readonly shortcuts = new ShortcutController(this);
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    if (this.editing) {
-      this.collapsed = false;
-    } else if (this.comment) {
-      this.collapsed = !!this.comment.collapsed;
-    }
-    this._getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
-    });
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, () => this._handleEsc())
+  constructor() {
+    super();
+    subscribe(this, this.userModel.account$, x => (this.account = x));
+    subscribe(this, this.userModel.isAdmin$, x => (this.isAdmin = x));
+    subscribe(
+      this,
+      this.configModel.repoCommentLinks$,
+      x => (this.commentLinks = x)
     );
+    subscribe(this, repo$, x => (this.repoName = x));
+    subscribe(this, changeNum$, x => (this.changeNum = x));
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc());
     for (const key of ['s', Key.ENTER]) {
       for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
-        addShortcut(this, {key, modifiers: [modifier]}, e =>
-          this._handleSaveKey(e)
-        );
+        this.shortcuts.addLocal({key, modifiers: [modifier]}, () => {
+          this.save();
+        });
       }
     }
   }
 
   override disconnectedCallback() {
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-    this.fireUpdateTask?.cancel();
-    this.storeTask?.cancel();
-    this.draftToastTask?.cancel();
-    if (this.textarea) {
-      this.textarea.closeDropdown();
-    }
+    // Clean up emoji dropdown.
+    if (this.textarea) this.textarea.closeDropdown();
     super.disconnectedCallback();
   }
 
-  /** 2nd argument is for *triggering* the computation only. */
-  _getAuthor(comment?: UIComment, _?: unknown) {
-    return comment?.author || this._selfAccount;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+          padding: var(--spacing-m);
+        }
+        :host([collapsed]) {
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        :host([saving]) {
+          pointer-events: none;
+        }
+        :host([saving]) .actions,
+        :host([saving]) .robotActions,
+        :host([saving]) .date {
+          opacity: 0.5;
+        }
+        .body {
+          padding-top: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          cursor: pointer;
+          display: flex;
+        }
+        .headerLeft > span {
+          font-weight: var(--font-weight-bold);
+        }
+        .headerMiddle {
+          color: var(--deemphasized-text-color);
+          flex: 1;
+          overflow: hidden;
+        }
+        .draftLabel,
+        .draftTooltip {
+          color: var(--deemphasized-text-color);
+          display: inline;
+        }
+        .date {
+          justify-content: flex-end;
+          text-align: right;
+          white-space: nowrap;
+        }
+        span.date {
+          color: var(--deemphasized-text-color);
+        }
+        span.date:hover {
+          text-decoration: underline;
+        }
+        .actions,
+        .robotActions {
+          display: flex;
+          justify-content: flex-end;
+          padding-top: 0;
+        }
+        .robotActions {
+          /* Better than the negative margin would be to remove the gr-button
+       * padding, but then we would also need to fix the buttons that are
+       * inserted by plugins. :-/ */
+          margin: 4px 0 -4px;
+        }
+        .action {
+          margin-left: var(--spacing-l);
+        }
+        .rightActions {
+          display: flex;
+          justify-content: flex-end;
+        }
+        .rightActions gr-button {
+          --gr-button-padding: 0 var(--spacing-s);
+        }
+        .editMessage {
+          display: block;
+          margin: var(--spacing-m) 0;
+          width: 100%;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+        }
+        .robotId {
+          color: var(--deemphasized-text-color);
+          margin-bottom: var(--spacing-m);
+        }
+        .robotRun {
+          margin-left: var(--spacing-m);
+        }
+        .robotRunLink {
+          margin-left: var(--spacing-m);
+        }
+        /* just for a11y */
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+        }
+        label.show-hide iron-icon {
+          vertical-align: top;
+        }
+        :host([collapsed]) #container .body {
+          padding-top: 0;
+        }
+        #container .collapsedContent {
+          display: block;
+          overflow: hidden;
+          padding-left: var(--spacing-m);
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .resolve,
+        .unresolved {
+          align-items: center;
+          display: flex;
+          flex: 1;
+          margin: 0;
+        }
+        .resolve label {
+          color: var(--comment-text-color);
+        }
+        gr-dialog .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        #deleteBtn {
+          --gr-button-text-color: var(--deemphasized-text-color);
+          --gr-button-padding: 0;
+        }
+
+        /** Disable select for the caret and actions */
+        .actions,
+        .show-hide {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+        }
+
+        .respectfulReviewTip {
+          justify-content: space-between;
+          display: flex;
+          padding: var(--spacing-m);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin-bottom: var(--spacing-m);
+        }
+        .respectfulReviewTip div {
+          display: flex;
+        }
+        .respectfulReviewTip div iron-icon {
+          margin-right: var(--spacing-s);
+        }
+        .respectfulReviewTip a {
+          white-space: nowrap;
+          margin-right: var(--spacing-s);
+          padding-left: var(--spacing-m);
+          text-decoration: none;
+        }
+        .pointer {
+          cursor: pointer;
+        }
+        .patchset-text {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-s);
+        }
+        .headerLeft gr-account-label {
+          --account-max-length: 130px;
+          width: 150px;
+        }
+        .headerLeft gr-account-label::part(gr-account-label-text) {
+          font-weight: var(--font-weight-bold);
+        }
+        .draft gr-account-label {
+          width: unset;
+        }
+        .portedMessage {
+          margin: 0 var(--spacing-m);
+        }
+        .link-icon {
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  _getUrlForComment(comment?: UIComment) {
-    if (!comment || !this.changeNum || !this.projectName) return '';
+  override render() {
+    if (isUnsaved(this.comment) && !this.editing) return;
+    const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <div id="container" class="${classMap(classes)}">
+        <div
+          class="header"
+          id="header"
+          @click="${() => (this.collapsed = !this.collapsed)}"
+        >
+          <div class="headerLeft">
+            ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
+            ${this.renderDraftLabel()}
+          </div>
+          <div class="headerMiddle">${this.renderCollapsedContent()}</div>
+          ${this.renderRunDetails()} ${this.renderDeleteButton()}
+          ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+        </div>
+        <div class="body">
+          ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+          ${this.renderRespectfulTip()} ${this.renderCommentMessage()}
+          ${this.renderHumanActions()} ${this.renderRobotActions()}
+        </div>
+      </div>
+      ${this.renderConfirmDialog()}
+    `;
+  }
+
+  private renderAuthor() {
+    if (isRobot(this.comment)) {
+      const id = this.comment.robot_id;
+      return html`<span class="robotName">${id}</span>`;
+    }
+    const classes = {draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <gr-account-label
+        .account="${this.comment?.author ?? this.account}"
+        class="${classMap(classes)}"
+        hideStatus
+      >
+      </gr-account-label>
+    `;
+  }
+
+  private renderPortedCommentMessage() {
+    if (!this.showPortedComment) return;
+    if (!this.comment?.patch_set) return;
+    return html`
+      <a href="${this.getUrlForComment()}">
+        <span class="portedMessage" @click="${this.handlePortedMessageClick}">
+          From patchset ${this.comment?.patch_set}]]
+        </span>
+      </a>
+    `;
+  }
+
+  private renderDraftLabel() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    let label = 'DRAFT';
+    let tooltip =
+      'This draft is only visible to you. ' +
+      "To publish drafts, click the 'Reply' or 'Start review' button " +
+      "at the top of the change or press the 'a' key.";
+    if (this.unableToSave) {
+      label += ' (Failed to save)';
+      tooltip = 'Unable to save draft. Please try to save again.';
+    }
+    return html`
+      <gr-tooltip-content
+        class="draftTooltip"
+        has-tooltip
+        title="${tooltip}"
+        max-width="20em"
+        show-icon
+      >
+        <span class="draftLabel">${label}</span>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderCollapsedContent() {
+    if (!this.collapsed) return;
+    return html`
+      <span class="collapsedContent">${this.comment?.message}</span>
+    `;
+  }
+
+  private renderRunDetails() {
+    if (!isRobot(this.comment)) return;
+    if (!this.comment?.url || this.collapsed) return;
+    return html`
+      <div class="runIdMessage message">
+        <div class="runIdInformation">
+          <a class="robotRunLink" href="${this.comment.url}">
+            <span class="robotRun link">Run Details</span>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Deleting a comment is an admin feature. It means more than just discarding
+   * a draft. It is an action applied to published comments.
+   */
+  private renderDeleteButton() {
+    if (
+      !this.isAdmin ||
+      isDraftOrUnsaved(this.comment) ||
+      isRobot(this.comment)
+    )
+      return;
+    if (this.collapsed) return;
+    return html`
+      <gr-button
+        id="deleteBtn"
+        title="Delete Comment"
+        link
+        class="action delete"
+        @click="${this.openDeleteCommentOverlay}"
+      >
+        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+    `;
+  }
+
+  private renderPatchset() {
+    if (!this.showPatchset) return;
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return html`
+      <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
+    `;
+  }
+
+  private renderDate() {
+    if (!this.comment?.updated || this.collapsed) return;
+    return html`
+      <span class="separator"></span>
+      <span class="date" tabindex="0" @click="${this.handleAnchorClick}">
+        <gr-date-formatter
+          withTooltip
+          .dateStr="${this.comment.updated}"
+        ></gr-date-formatter>
+      </span>
+    `;
+  }
+
+  private renderToggle() {
+    const icon = this.collapsed
+      ? 'gr-icons:expand-more'
+      : 'gr-icons:expand-less';
+    const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+    return html`
+      <div class="show-hide" tabindex="0">
+        <label class="show-hide" aria-label="${ariaLabel}">
+          <input
+            type="checkbox"
+            class="show-hide"
+            ?checked="${this.collapsed}"
+            @change="${() => (this.collapsed = !this.collapsed)}"
+          />
+          <iron-icon id="icon" icon="${icon}"></iron-icon>
+        </label>
+      </div>
+    `;
+  }
+
+  private renderRobotAuthor() {
+    if (!isRobot(this.comment) || this.collapsed) return;
+    return html`<div class="robotId">${this.comment.author?.name}</div>`;
+  }
+
+  private renderEditingTextarea() {
+    if (!this.editing || this.collapsed) return;
+    return html`
+      <gr-textarea
+        id="editTextarea"
+        class="editMessage"
+        autocomplete="on"
+        code=""
+        ?disabled="${this.saving}"
+        rows="4"
+        text="${this.messageText}"
+        @text-changed="${(e: ValueChangedEvent) => {
+          // TODO: This is causing a re-render of <gr-comment> on every key
+          // press. Try to avoid always setting `this.messageText` or at least
+          // debounce it. Can have another look when introducing auto-saving.
+          // But typically most of the code can just inspect the current value
+          // of the textare instead of needing a dedicated property.
+          this.messageText = e.detail.value;
+        }}"
+      ></gr-textarea>
+    `;
+  }
+
+  private renderRespectfulTip() {
+    if (!this.showRespectfulTip || this.respectfulTipDismissed) return;
+    if (this.collapsed) return;
+    return html`
+      <div class="respectfulReviewTip">
+        <div>
+          <gr-tooltip-content
+            has-tooltip
+            title="Tips for respectful code reviews."
+          >
+            <iron-icon
+              class="pointer"
+              icon="gr-icons:lightbulb-outline"
+            ></iron-icon>
+          </gr-tooltip-content>
+          ${this.respectfulReviewTip}
+        </div>
+        <div>
+          <a
+            tabindex="-1"
+            @click="${this.onRespectfulReadMoreClick}"
+            href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+            target="_blank"
+          >
+            Read more
+          </a>
+          <a
+            tabindex="-1"
+            class="close pointer"
+            @click="${this.dismissRespectfulTip}"
+          >
+            Not helpful
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderCommentMessage() {
+    if (this.collapsed || this.editing) return;
+    return html`
+      <!--The "message" class is needed to ensure selectability from
+          gr-diff-selection.-->
+      <gr-formatted-text
+        class="message"
+        .content="${this.comment?.message}"
+        .config="${this.commentLinks}"
+        ?noTrailingMargin="${!isDraftOrUnsaved(this.comment)}"
+      ></gr-formatted-text>
+    `;
+  }
+
+  private renderCopyLinkIcon() {
+    // Only show the icon when the thread contains a published comment.
+    if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <iron-icon
+        class="copy link-icon"
+        @click="${this.handleCopyLink}"
+        title="Copy link to this comment"
+        icon="gr-icons:link"
+        role="button"
+        tabindex="0"
+      >
+      </iron-icon>
+    `;
+  }
+
+  private renderHumanActions() {
+    if (!this.account || isRobot(this.comment)) return;
+    if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="actions">
+        <div class="action resolve">
+          <label>
+            <input
+              type="checkbox"
+              id="resolvedCheckbox"
+              ?checked="${!this.unresolved}"
+              @change="${this.handleToggleResolved}"
+            />
+            Resolved
+          </label>
+        </div>
+        ${this.renderDraftActions()}
+      </div>
+    `;
+  }
+
+  private renderDraftActions() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="rightActions">
+        ${this.renderCopyLinkIcon()} ${this.renderDiscardButton()}
+        ${this.renderEditButton()} ${this.renderCancelButton()}
+        ${this.renderSaveButton()}
+      </div>
+    `;
+  }
+
+  private renderDiscardButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled="${this.saving}"
+      class="action discard"
+      @click="${this.discard}"
+      >Discard</gr-button
+    >`;
+  }
+
+  private renderEditButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled="${this.saving}"
+      class="action edit"
+      @click="${this.edit}"
+      >Edit</gr-button
+    >`;
+  }
+
+  private renderCancelButton() {
+    if (!this.editing) return;
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.saving}"
+        class="action cancel"
+        @click="${this.cancel}"
+        >Cancel</gr-button
+      >
+    `;
+  }
+
+  private renderSaveButton() {
+    if (!this.editing && !this.unableToSave) return;
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.isSaveDisabled()}"
+        class="action save"
+        @click="${this.save}"
+        >Save</gr-button
+      >
+    `;
+  }
+
+  private renderRobotActions() {
+    if (!this.account || !isRobot(this.comment)) return;
+    const endpoint = html`
+      <gr-endpoint-decorator name="robot-comment-controls">
+        <gr-endpoint-param name="comment" .value="${this.comment}">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+    return html`
+      <div class="robotActions">
+        ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
+        ${this.renderPleaseFixButton()}
+      </div>
+    `;
+  }
+
+  private renderShowFixButton() {
+    if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+    return html`
+      <gr-button
+        link
+        secondary
+        class="action show-fix"
+        ?disabled="${this.saving}"
+        @click="${this.handleShowFix}"
+      >
+        Show Fix
+      </gr-button>
+    `;
+  }
+
+  private renderPleaseFixButton() {
+    if (this.hasHumanReply()) return;
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.robotButtonDisabled}"
+        class="action fix"
+        @click="${this.handleFix}"
+      >
+        Please Fix
+      </gr-button>
+    `;
+  }
+
+  private renderConfirmDialog() {
+    if (!this.showConfirmDeleteOverlay) return;
+    return html`
+      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+        <gr-confirm-delete-comment-dialog
+          id="confirmDeleteComment"
+          @confirm="${this.handleConfirmDeleteComment}"
+          @cancel="${this.closeDeleteCommentOverlay}"
+        >
+        </gr-confirm-delete-comment-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private getUrlForComment() {
+    const comment = this.comment;
+    if (!comment || !this.changeNum || !this.repoName) return '';
     if (!comment.id) throw new Error('comment must have an id');
     return GerritNav.getUrlForComment(
       this.changeNum as NumericChangeId,
-      this.projectName,
+      this.repoName,
       comment.id
     );
   }
 
-  _handlePortedMessageClick() {
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    assertIsDefined(this.comment, 'comment');
+    this.unresolved = this.comment.unresolved ?? true;
+    this.messageText = this.comment.message ?? '';
+    if (isUnsaved(this.comment)) this.editing = true;
+    if (isDraftOrUnsaved(this.comment)) {
+      this.collapsed = false;
+    } else {
+      this.collapsed = !!this.initiallyCollapsed;
+    }
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('editing')) {
+      this.onEditingChanged();
+    }
+    if (changed.has('unresolved')) {
+      // The <gr-comment-thread> component wants to change its color based on
+      // the (dirty) unresolved state, so let's notify it about changes.
+      fire(this, 'comment-unresolved-changed', this.unresolved);
+    }
+  }
+
+  private handlePortedMessageClick() {
     assertIsDefined(this.comment, 'comment');
     this.reporting.reportInteraction('navigate-to-original-comment', {
       line: this.comment.line,
@@ -348,753 +890,220 @@
     });
   }
 
-  @observe('editing')
-  _onEditingChange(editing?: boolean) {
-    this.dispatchEvent(
-      new CustomEvent('comment-editing-changed', {
-        detail: !!editing,
-        bubbles: true,
-        composed: true,
-      })
+  // private, but visible for testing
+  getRandomInt(from: number, to: number) {
+    return getRandomInt(from, to);
+  }
+
+  private dismissRespectfulTip() {
+    this.respectfulTipDismissed = true;
+    this.reporting.reportInteraction('respectful-tip-dismissed', {
+      tip: this.respectfulReviewTip,
+    });
+    // add a 14-day delay to the tip cache
+    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+  }
+
+  private onRespectfulReadMoreClick() {
+    this.reporting.reportInteraction('respectful-read-more-clicked');
+  }
+
+  private handleCopyLink() {
+    fireEvent(this, 'copy-comment-link');
+  }
+
+  /** Enter editing mode. */
+  private edit() {
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('Cannot edit published comment.');
+    }
+    if (this.editing) return;
+    if (this.comment?.message) this.messageText = this.comment.message;
+    this.editing = true;
+    this.collapsed = false;
+  }
+
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property.
+  private hasHumanReply() {
+    if (!this.comment || !this.comments) return false;
+    return this.comments.some(
+      c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
     );
-    if (!editing) return;
+  }
+
+  // private, but visible for testing
+  getEventPayload(): OpenFixPreviewEventDetail {
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return {comment: this.comment, patchNum: this.comment.patch_set};
+  }
+
+  private onEditingChanged() {
+    if (this.editing) {
+      this.collapsed = false;
+      setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+    }
+    this.setRespectfulTip();
+
+    // Parent components such as the reply dialog might be interested in whether
+    // come of their child components are in editing mode.
+    fire(this, 'comment-editing-changed', this.editing);
+  }
+
+  private setRespectfulTip() {
     // visibility based on cache this will make sure we only and always show
     // a tip once every Math.max(a day, period between creating comments)
     const cachedVisibilityOfRespectfulTip =
       this.storage.getRespectfulTipVisibility();
-    if (!cachedVisibilityOfRespectfulTip) {
-      // we still want to show the tip with a probability of 30%
-      if (this.getRandomNum(0, 3) >= 1) return;
-      this._showRespectfulTip = true;
-      const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
-      this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
+    if (this.editing && !cachedVisibilityOfRespectfulTip) {
+      // we still want to show the tip with a probability of 33%
+      if (this.getRandomInt(0, 2) >= 1) return;
+      this.showRespectfulTip = true;
+      const randomIdx = this.getRandomInt(0, RESPECTFUL_REVIEW_TIPS.length);
+      this.respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
       this.reporting.reportInteraction('respectful-tip-appeared', {
-        tip: this._respectfulReviewTip,
+        tip: this.respectfulReviewTip,
       });
       // update cache
       this.storage.setRespectfulTipVisibility();
     }
   }
 
-  /** Set as a separate method so easy to stub. */
-  getRandomNum(min: number, max: number) {
-    return Math.floor(Math.random() * (max - min) + min);
+  // private, but visible for testing
+  isSaveDisabled() {
+    assertIsDefined(this.comment, 'comment');
+    if (this.saving) return true;
+    if (this.comment.unresolved !== this.unresolved) return false;
+    return !this.messageText?.trim();
   }
 
-  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
-    return showTip && !tipDismissed;
+  private handleEsc() {
+    // vim users don't like ESC to cancel/discard, so only do this when the
+    // comment text is empty.
+    if (!this.messageText?.trim()) this.cancel();
   }
 
-  _dismissRespectfulTip() {
-    this._respectfulTipDismissed = true;
-    this.reporting.reportInteraction('respectful-tip-dismissed', {
-      tip: this._respectfulReviewTip,
+  private handleAnchorClick() {
+    assertIsDefined(this.comment, 'comment');
+    fire(this, 'comment-anchor-tap', {
+      number: this.comment.line || FILE,
+      side: this.comment?.side,
     });
-    // add a 14-day delay to the tip cache
-    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
   }
 
-  _onRespectfulReadMoreClick() {
-    this.reporting.reportInteraction('respectful-read-more-clicked');
+  private handleFix() {
+    // Handled by <gr-comment-thread>.
+    fire(this, 'create-fix-comment', this.getEventPayload());
   }
 
-  get textarea(): GrTextarea | null {
-    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+  private handleShowFix() {
+    // Handled top-level in the diff and change view components.
+    fire(this, 'open-fix-preview', this.getEventPayload());
   }
 
-  get confirmDeleteOverlay() {
-    if (!this._overlays.confirmDelete) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
-        '#confirmDeleteOverlay'
-      ) as GrOverlay | null;
+  // private, but visible for testing
+  cancel() {
+    assertIsDefined(this.comment, 'comment');
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('only unsaved and draft comments are editable');
     }
-    return this._overlays.confirmDelete;
-  }
+    if (this.saving) throw new Error('Saving in progress.');
 
-  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;
-  }
-
-  hasPublishedComment(comments?: UIComment[]) {
-    if (!comments?.length) return false;
-    return comments.length > 1 || !isDraft(comments[0]);
-  }
-
-  @observe('comment')
-  _isRobotComment(comment: UIRobot) {
-    this.isRobotComment = !!comment.robot_id;
-  }
-
-  isOnParent() {
-    return this.side === 'PARENT';
-  }
-
-  _getIsAdmin() {
-    return this.restApiService.getIsAdmin();
-  }
-
-  _computeDraftTooltip(unableToSave: boolean) {
-    return unableToSave
-      ? 'Unable to save draft. Please try to save again.'
-      : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
-          "or 'Start review' button at the top of the change or press the 'A' key.";
-  }
-
-  _computeDraftText(unableToSave: boolean) {
-    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
-  }
-
-  handleCopyLink() {
-    fireEvent(this, 'copy-comment-link');
-  }
-
-  save(opt_comment?: UIComment) {
-    let comment = opt_comment;
-    if (!comment) {
-      comment = this.comment;
-    }
-
-    this.set('comment.message', this._messageText);
     this.editing = false;
-    this.disabled = true;
+    this.messageText = this.comment.message ?? '';
+    this.unresolved = this.comment.unresolved ?? true;
+  }
 
-    if (!this._messageText) {
-      return this._discardDraft();
-    }
-
-    const details = this.commentDetailsForReporting();
-    this.reporting.reportInteraction(Interaction.SAVE_COMMENT, details);
-    this._xhrPromise = this._saveDraft(comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          return;
-        }
-
-        this._eraseDraftCommentFromStorage();
-        return this.restApiService.getResponseObject(response).then(obj => {
-          const resComment = obj as unknown as UIDraft;
-          if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
-          resComment.__draft = true;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment?.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
-          this.comment = resComment;
-          const details = this.commentDetailsForReporting();
-          this.reporting.reportInteraction(Interaction.COMMENT_SAVED, details);
-          this._fireSave();
-          return obj;
-        });
+  // private, but visible for testing
+  async save() {
+    if (!this.messageText?.trim()) return this.discard();
+    await this.writeOperation(draft =>
+      this.commentsService.saveDraft({
+        ...draft,
+        message: this.messageText,
+        unresolved: this.unresolved,
       })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
+    );
   }
 
-  private commentDetailsForReporting() {
-    return {
-      id: this.comment?.id,
-      message_length: this.comment?.message?.length,
-      in_reply_to: this.comment?.in_reply_to,
-      unresolved: this.comment?.unresolved,
-      path_length: this.comment?.path?.length,
-      line: this.comment?.range?.start_line ?? this.comment?.line,
-    };
-  }
-
-  _eraseDraftCommentFromStorage() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.storeTask?.cancel();
-
-    assertIsDefined(this.comment?.path, 'comment.path');
-    assertIsDefined(this.changeNum, 'changeNum');
-    this.storage.eraseDraftComment({
-      changeNum: this.changeNum,
-      patchNum: this._getPatchNum(),
-      path: this.comment.path,
-      line: this.comment.line,
-      range: this.comment.range,
+  // private, but visible for testing
+  async discard() {
+    await this.writeOperation((draft: DraftInfo | UnsavedInfo) => {
+      assertIsDefined(draft.id, 'comment id');
+      return this.commentsService.discardDraft(draft.id);
     });
   }
 
-  _commentChanged(comment: UIComment) {
-    this.editing = isDraft(comment) && !!comment.__editing;
-    this.resolved = !comment.unresolved;
-    this.discarding = false;
-    if (this.editing) {
-      // It's a new draft/reply, notify.
-      this._fireUpdate();
-    }
-  }
-
-  @observe('comment', 'comments.*')
-  _computeHasHumanReply() {
-    const comment = this.comment;
-    if (!comment || !this.comments) return;
-    // hide please fix button for robot comment that has human reply
-    this._hasHumanReply = this.comments.some(
-      c =>
-        c.in_reply_to &&
-        c.in_reply_to === comment.id &&
-        !(c as UIRobot).robot_id
-    );
-  }
-
-  _getEventPayload(): OpenFixPreviewEventDetail {
-    return {comment: this.comment, patchNum: this.patchNum};
-  }
-
-  _fireEdit() {
-    if (this.comment) this.commentsService.editDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-edit', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireSave() {
-    if (this.comment) this.commentsService.addDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-save', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireUpdate() {
-    this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
-      this.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: this._getEventPayload(),
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  }
-
-  _computeAccountLabelClass(draft: boolean) {
-    return draft ? 'draft' : '';
-  }
-
-  _draftChanged(draft: boolean) {
-    this.$.container.classList.toggle('draft', draft);
-  }
-
-  _editingChanged(editing?: boolean, previousValue?: boolean) {
-    // Polymer 2: observer fires when at least one property is defined.
-    // Do nothing to prevent comment.__editing being overwritten
-    // if previousValue is undefined
-    if (previousValue === undefined) return;
-
-    this.$.container.classList.toggle('editing', editing);
-    if (this.comment && this.comment.id) {
-      const cancelButton = this.shadowRoot?.querySelector(
-        '.cancel'
-      ) as GrButton | null;
-      if (cancelButton) {
-        cancelButton.hidden = !editing;
-      }
-    }
-    if (isDraft(this.comment)) {
-      this.comment.__editing = this.editing;
-    }
-    if (!!editing !== !!previousValue) {
-      // To prevent event firing on comment creation.
-      this._fireUpdate();
-    }
-    if (editing) {
-      setTimeout(() => {
-        flush();
-        this.textarea && this.textarea.putCursorAtEnd();
-      }, 1);
-    }
-  }
-
-  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
-    return isAdmin && !draft ? 'showDeleteButtons' : '';
-  }
-
-  _computeSaveDisabled(
-    draft: string,
-    comment: UIComment | undefined,
-    resolved?: boolean
+  private async writeOperation(
+    op: (draft: DraftInfo | UnsavedInfo) => Promise<void>
   ) {
-    // If resolved state has changed and a msg exists, save should be enabled.
-    if (!comment || (comment.unresolved === resolved && draft)) {
-      return false;
+    assertIsDefined(this.comment, 'comment');
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('only unsaved and draft comments are editable');
     }
-    return !draft || draft.trim() === '';
-  }
+    if (this.saving) throw new Error('Saving already in progress.');
 
-  _handleSaveKey(e: Event) {
-    if (
-      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
-    ) {
-      e.preventDefault();
-      this._handleSave(e);
-    }
-  }
-
-  _handleEsc() {
-    if (!this._messageText.length) {
-      this._handleCancel();
-    }
-  }
-
-  _handleToggleCollapsed() {
-    this.collapsed = !this.collapsed;
-  }
-
-  _toggleCollapseClass(collapsed: boolean) {
-    if (collapsed) {
-      this.$.container.classList.add('collapsed');
-    } else {
-      this.$.container.classList.remove('collapsed');
-    }
-  }
-
-  @observe('comment.message')
-  _commentMessageChanged(message: string) {
-    /*
-     * Only overwrite the message text user has typed if there is no existing
-     * text typed by the user. This prevents the bug where creating another
-     * comment triggered a recomputation of comments and the text written by
-     * the user was lost.
-     */
-    if (!this._messageText || !this.editing) this._messageText = message || '';
-  }
-
-  _messageTextChanged(_: string, oldValue: string) {
-    // Only store comments that are being edited in local storage.
-    if (
-      !this.comment ||
-      (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
-    ) {
-      return;
-    }
-
-    const patchNum = this.comment.patch_set
-      ? this.comment.patch_set
-      : this._getPatchNum();
-    const {path, line, range} = this.comment;
-    if (!path) return;
-    this.storeTask = debounce(
-      this.storeTask,
-      () => {
-        const message = this._messageText;
-        if (this.changeNum === undefined) {
-          throw new Error('undefined changeNum');
-        }
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum,
-          path,
-          line,
-          range,
-        };
-
-        if ((!message || !message.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.storage.setDraftComment(commentLocation, message);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _handleAnchorClick(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    this.dispatchEvent(
-      new CustomEvent('comment-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          number: this.comment.line || FILE,
-          side: this.side,
-        },
-      })
-    );
-  }
-
-  _handleEdit(e: Event) {
-    e.preventDefault();
-    if (this.comment?.message) this._messageText = this.comment.message;
-    this.editing = true;
-    this._fireEdit();
-    this.reporting.recordDraftInteraction();
-  }
-
-  _handleSave(e: Event) {
-    e.preventDefault();
-
-    // Ignore saves started while already saving.
-    if (this.disabled) return;
-    const timingLabel = this.comment?.id
-      ? REPORT_UPDATE_DRAFT
-      : REPORT_CREATE_DRAFT;
-    const timer = this.reporting.getTimer(timingLabel);
-    this.set('comment.__editing', false);
-    return this.save().then(() => {
-      timer.end({id: this.comment?.id});
-    });
-  }
-
-  _handleCancel() {
-    if (!this.comment) return;
-    if (!this.comment.id) {
-      // Ensures we update the discarded draft message before deleting the draft
-      this.set('comment.message', this._messageText);
-      this._fireDiscard();
-    } else {
-      this.set('comment.__editing', false);
-      this.commentsService.cancelDraft(this.comment);
+    try {
+      this.saving = true;
+      this.unableToSave = false;
+      await op(this.comment);
       this.editing = false;
+    } catch (e) {
+      this.unableToSave = true;
+      throw e;
+    } finally {
+      this.saving = false;
     }
   }
 
-  _fireDiscard() {
-    if (this.comment) this.commentsService.deleteDraft(this.comment);
-    this.fireUpdateTask?.cancel();
-    this.dispatchEvent(
-      new CustomEvent('comment-discard', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private handleToggleResolved() {
+    this.unresolved = !this.unresolved;
+    if (!this.editing) this.save();
   }
 
-  _handleFix() {
-    this.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
+  private async openDeleteCommentOverlay() {
+    this.showConfirmDeleteOverlay = true;
+    await this.updateComplete;
+    this.confirmDeleteOverlay?.open();
   }
 
-  _handleShowFix() {
-    this.dispatchEvent(
-      new CustomEvent('open-fix-preview', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
+  private closeDeleteCommentOverlay() {
+    this.showConfirmDeleteOverlay = false;
+    this.confirmDeleteOverlay?.remove();
+    this.confirmDeleteOverlay?.close();
   }
 
-  _hasNoFix(comment?: UIComment) {
-    return !comment || !(comment as UIRobot).fix_suggestions;
-  }
-
-  _handleDiscard(e: Event) {
-    e.preventDefault();
-    this.reporting.recordDraftInteraction();
-
-    this._discardDraft();
-  }
-
-  _discardDraft() {
-    if (!this.comment) return Promise.reject(new Error('undefined comment'));
-    if (!isDraft(this.comment)) {
-      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
-    }
-    this.discarding = true;
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this.editing = false;
-    this.disabled = true;
-    this._eraseDraftCommentFromStorage();
-
-    if (!this.comment.id) {
-      this.disabled = false;
-      this._fireDiscard();
-      return Promise.resolve();
-    }
-
-    this._xhrPromise = this._deleteDraft(this.comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          this.discarding = false;
-        }
-        timer.end({id: this.comment?.id});
-        this._fireDiscard();
-        return response;
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  _getSavingMessage(numPending: number, requestFailed?: boolean) {
-    if (requestFailed) {
-      return UNSAVED_MESSAGE;
-    }
-    if (numPending === 0) {
-      return SAVED_MESSAGE;
-    }
-    return `Saving ${pluralize(numPending, 'draft')}...`;
-  }
-
-  _showStartRequest() {
-    const numPending = ++this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _showEndRequest() {
-    const numPending = --this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _handleFailedDraftRequest() {
-    this._numPendingDraftRequests.number--;
-
-    // Cancel the debouncer so that error toasts from the error-manager will
-    // not be overridden.
-    this.draftToastTask?.cancel();
-    this._updateRequestToast(
-      this._numPendingDraftRequests.number,
-      /* requestFailed=*/ true
-    );
-  }
-
-  _updateRequestToast(numPending: number, requestFailed?: boolean) {
-    const message = this._getSavingMessage(numPending, requestFailed);
-    this.draftToastTask = debounce(
-      this.draftToastTask,
-      () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        fireAlert(document.body, message);
-      },
-      TOAST_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _handleDraftFailure() {
-    this.$.container.classList.add('unableToSave');
-    this._unableToSave = true;
-    this._handleFailedDraftRequest();
-  }
-
-  _saveDraft(draft?: UIComment) {
-    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
-      throw new Error('undefined draft or changeNum or patchNum');
-    }
-    this._showStartRequest();
-    return this.restApiService
-      .saveDiffDraft(this.changeNum, this.patchNum, draft)
-      .then(result => {
-        if (result.ok) {
-          // remove
-          this._unableToSave = false;
-          this.$.container.classList.remove('unableToSave');
-          this._showEndRequest();
-        } else {
-          this._handleDraftFailure();
-        }
-        return result;
-      })
-      .catch(err => {
-        this._handleDraftFailure();
-        throw err;
-      });
-  }
-
-  _deleteDraft(draft: UIComment) {
-    const changeNum = this.changeNum;
-    const patchNum = this.patchNum;
-    if (changeNum === undefined || patchNum === undefined) {
-      throw new Error('undefined changeNum or patchNum');
-    }
-    fireAlert(this, 'Discarding draft...');
-    const draftID = draft.id;
-    if (!draftID) throw new Error('Missing id in comment draft.');
-    return this.restApiService
-      .deleteDiffDraft(changeNum, patchNum, {id: draftID})
-      .then(result => {
-        if (result.ok) {
-          fire(this, 'show-alert', {
-            message: 'Draft Discarded',
-            action: 'Undo',
-            callback: () =>
-              this.commentsService.restoreDraft(changeNum, patchNum, draftID),
-          });
-        }
-        return result;
-      });
-  }
-
-  _getPatchNum(): PatchSetNum {
-    const patchNum = this.isOnParent()
-      ? ('PARENT' as BasePatchSetNum)
-      : this.patchNum;
-    if (patchNum === undefined) throw new Error('patchNum undefined');
-    return patchNum;
-  }
-
-  @observe('changeNum', 'patchNum', 'comment')
-  _loadLocalDraft(
-    changeNum: number,
-    patchNum?: PatchSetNum,
-    comment?: UIComment
-  ) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].includes(undefined)) {
-      return;
-    }
-
-    // Only apply local drafts to comments that are drafts and are currently
-    // being edited.
-    if (
-      !comment ||
-      !comment.path ||
-      comment.message ||
-      !isDraft(comment) ||
-      !comment.__editing
-    ) {
-      return;
-    }
-
-    const draft = this.storage.getDraftComment({
-      changeNum,
-      patchNum: this._getPatchNum(),
-      path: comment.path,
-      line: comment.line,
-      range: comment.range,
-    });
-
-    if (draft) {
-      this._messageText = draft.message || '';
-    }
-  }
-
-  _handleToggleResolved() {
-    this.reporting.recordDraftInteraction();
-    this.resolved = !this.resolved;
-    // Modify payload instead of this.comment, as this.comment is passed from
-    // the parent by ref.
-    const payload = this._getEventPayload();
-    if (!payload.comment) {
-      throw new Error('comment not defined in payload');
-    }
-    payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-    this.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: payload,
-        composed: true,
-        bubbles: true,
-      })
-    );
-    if (!this.editing) {
-      // Save the resolved state immediately.
-      this.save(payload.comment);
-    }
-  }
-
-  _handleCommentDelete() {
-    this._openOverlay(this.confirmDeleteOverlay);
-  }
-
-  _handleCancelDeleteComment() {
-    this._closeOverlay(this.confirmDeleteOverlay);
-  }
-
-  _openOverlay(overlay?: GrOverlay | null) {
-    if (!overlay) {
-      return Promise.reject(new Error('undefined overlay'));
-    }
-    getRootElement().appendChild(overlay);
-    return overlay.open();
-  }
-
-  _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
-    if (!comment) return true;
-    if (!isRobot(comment)) return true;
-    return !comment.url || collapsed;
-  }
-
-  _closeOverlay(overlay?: GrOverlay | null) {
-    if (overlay) {
-      getRootElement().removeChild(overlay);
-      overlay.close();
-    }
-  }
-
-  _handleConfirmDeleteComment() {
+  /**
+   * Deleting a *published* comment is an admin feature. It means more than just
+   * discarding a draft.
+   *
+   * TODO: Also move this into the comments-service.
+   * TODO: Figure out a good reloading strategy when deleting was successful.
+   *       `this.comment = newComment` does not seem sufficient.
+   */
+  // private, but visible for testing
+  handleConfirmDeleteComment() {
     const dialog = this.confirmDeleteOverlay?.querySelector(
       '#confirmDeleteComment'
     ) as GrConfirmDeleteCommentDialog | null;
     if (!dialog || !dialog.message) {
       throw new Error('missing confirm delete dialog');
     }
-    if (
-      !this.comment ||
-      !this.comment.id ||
-      this.changeNum === undefined ||
-      this.patchNum === undefined
-    ) {
-      throw new Error('undefined comment or id or changeNum or patchNum');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.comment, 'comment');
+    assertIsDefined(this.comment.patch_set, 'comment.patch_set');
+    if (isDraftOrUnsaved(this.comment)) {
+      throw new Error('Admin deletion is only for published comments.');
     }
     this.restApiService
       .deleteComment(
         this.changeNum,
-        this.patchNum,
+        this.comment.patch_set,
         this.comment.id,
         dialog.message
       )
       .then(newComment => {
-        this._handleCancelDeleteComment();
+        this.closeDeleteCommentOverlay();
         this.comment = newComment;
       });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
deleted file mode 100644
index b77c4b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      font-family: var(--font-family);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) {
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .actions,
-    :host([disabled]) .robotActions,
-    :host([disabled]) .date {
-      opacity: 0.5;
-    }
-    :host([discarding]) {
-      display: none;
-    }
-    .body {
-      padding-top: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      cursor: pointer;
-      display: flex;
-    }
-    .headerLeft > span {
-      font-weight: var(--font-weight-bold);
-    }
-    .headerMiddle {
-      color: var(--deemphasized-text-color);
-      flex: 1;
-      overflow: hidden;
-    }
-    .draftLabel,
-    .draftTooltip {
-      color: var(--deemphasized-text-color);
-      display: none;
-    }
-    .date {
-      justify-content: flex-end;
-      text-align: right;
-      white-space: nowrap;
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .actions,
-    .robotActions {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: 0;
-    }
-    .robotActions {
-      /* Better than the negative margin would be to remove the gr-button
-       * padding, but then we would also need to fix the buttons that are
-       * inserted by plugins. :-/ */
-      margin: 4px 0 -4px;
-    }
-    .action {
-      margin-left: var(--spacing-l);
-    }
-    .rightActions {
-      display: flex;
-      justify-content: flex-end;
-    }
-    .rightActions gr-button {
-      --gr-button-padding: 0 var(--spacing-s);
-    }
-    .editMessage {
-      display: none;
-      margin: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .container:not(.draft) .actions .hideOnPublished {
-      display: none;
-    }
-    .draft .reply,
-    .draft .quote,
-    .draft .ack,
-    .draft .done {
-      display: none;
-    }
-    .draft .draftLabel,
-    .draft .draftTooltip {
-      display: inline;
-    }
-    .draft:not(.editing):not(.unableToSave) .save,
-    .draft:not(.editing) .cancel {
-      display: none;
-    }
-    .editing .message,
-    .editing .reply,
-    .editing .quote,
-    .editing .ack,
-    .editing .done,
-    .editing .edit,
-    .editing .discard,
-    .editing .unresolved {
-      display: none;
-    }
-    .editing .editMessage {
-      display: block;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-    }
-    .robotId {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    .robotRun {
-      margin-left: var(--spacing-m);
-    }
-    .robotRunLink {
-      margin-left: var(--spacing-m);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-    }
-    label.show-hide iron-icon {
-      vertical-align: top;
-    }
-    #container .collapsedContent {
-      display: none;
-    }
-    #container.collapsed .body {
-      padding-top: 0;
-    }
-    #container.collapsed .collapsedContent {
-      display: block;
-      overflow: hidden;
-      padding-left: var(--spacing-m);
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    #container.collapsed #deleteBtn,
-    #container.collapsed .date,
-    #container.collapsed .actions,
-    #container.collapsed gr-formatted-text,
-    #container.collapsed gr-textarea,
-    #container.collapsed .respectfulReviewTip {
-      display: none;
-    }
-    .resolve,
-    .unresolved {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      margin: 0;
-    }
-    .resolve label {
-      color: var(--comment-text-color);
-    }
-    gr-dialog .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    #deleteBtn {
-      display: none;
-      --gr-button-text-color: var(--deemphasized-text-color);
-      --gr-button-padding: 0;
-    }
-    #deleteBtn.showDeleteButtons {
-      display: block;
-    }
-
-    /** Disable select for the caret and actions */
-    .actions,
-    .show-hide {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .respectfulReviewTip {
-      justify-content: space-between;
-      display: flex;
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-bottom: var(--spacing-m);
-    }
-    .respectfulReviewTip div {
-      display: flex;
-    }
-    .respectfulReviewTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .respectfulReviewTip a {
-      white-space: nowrap;
-      margin-right: var(--spacing-s);
-      padding-left: var(--spacing-m);
-      text-decoration: none;
-    }
-    .pointer {
-      cursor: pointer;
-    }
-    .patchset-text {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-s);
-    }
-    .headerLeft gr-account-label {
-      --account-max-length: 130px;
-      width: 150px;
-    }
-    .headerLeft gr-account-label::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    .draft gr-account-label {
-      width: unset;
-    }
-    .portedMessage {
-      margin: 0 var(--spacing-m);
-    }
-    .link-icon {
-      cursor: pointer;
-    }
-  </style>
-  <div id="container" class="container">
-    <div class="header" id="header" on-click="_handleToggleCollapsed">
-      <div class="headerLeft">
-        <template is="dom-if" if="[[comment.robot_id]]">
-          <span class="robotName"> [[comment.robot_id]] </span>
-        </template>
-        <template is="dom-if" if="[[!comment.robot_id]]">
-          <gr-account-label
-            account="[[_getAuthor(comment, _selfAccount)]]"
-            class$="[[_computeAccountLabelClass(draft)]]"
-            hideStatus
-          >
-          </gr-account-label>
-        </template>
-        <template is="dom-if" if="[[showPortedComment]]">
-          <a href="[[_getUrlForComment(comment)]]"
-            ><span class="portedMessage" on-click="_handlePortedMessageClick"
-              >From patchset [[comment.patch_set]]</span
-            ></a
-          >
-        </template>
-        <gr-tooltip-content
-          class="draftTooltip"
-          has-tooltip
-          title="[[_computeDraftTooltip(_unableToSave)]]"
-          max-width="20em"
-          show-icon
-        >
-          <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
-        </gr-tooltip-content>
-      </div>
-      <div class="headerMiddle">
-        <span class="collapsedContent">[[comment.message]]</span>
-      </div>
-      <div
-        hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
-        class="runIdMessage message"
-      >
-        <div class="runIdInformation">
-          <a class="robotRunLink" href$="[[comment.url]]">
-            <span class="robotRun link">Run Details</span>
-          </a>
-        </div>
-      </div>
-      <gr-button
-        id="deleteBtn"
-        title="Delete Comment"
-        link=""
-        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-        hidden$="[[isRobotComment]]"
-        on-click="_handleCommentDelete"
-      >
-        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
-      </gr-button>
-      <template is="dom-if" if="[[showPatchset]]">
-        <span class="patchset-text"> Patchset [[patchNum]]</span>
-      </template>
-      <span class="separator"></span>
-      <template is="dom-if" if="[[comment.updated]]">
-        <span class="date" tabindex="0" on-click="_handleAnchorClick">
-          <gr-date-formatter
-            withTooltip
-            date-str="[[comment.updated]]"
-          ></gr-date-formatter>
-        </span>
-      </template>
-      <div class="show-hide" tabindex="0">
-        <label
-          class="show-hide"
-          aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
-        >
-          <input
-            type="checkbox"
-            class="show-hide"
-            checked$="[[collapsed]]"
-            on-change="_handleToggleCollapsed"
-          />
-          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
-          </iron-icon>
-        </label>
-      </div>
-    </div>
-    <div class="body">
-      <template is="dom-if" if="[[isRobotComment]]">
-        <div class="robotId" hidden$="[[collapsed]]">
-          [[comment.author.name]]
-        </div>
-      </template>
-      <template is="dom-if" if="[[editing]]">
-        <gr-textarea
-          id="editTextarea"
-          class="editMessage"
-          autocomplete="on"
-          code=""
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{_messageText}}"
-        ></gr-textarea>
-        <template
-          is="dom-if"
-          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
-        >
-          <div class="respectfulReviewTip">
-            <div>
-              <gr-tooltip-content
-                has-tooltip
-                title="Tips for respectful code reviews."
-              >
-                <iron-icon
-                  class="pointer"
-                  icon="gr-icons:lightbulb-outline"
-                ></iron-icon>
-              </gr-tooltip-content>
-              [[_respectfulReviewTip]]
-            </div>
-            <div>
-              <a
-                tabindex="-1"
-                on-click="_onRespectfulReadMoreClick"
-                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
-                target="_blank"
-              >
-                Read more
-              </a>
-              <a
-                tabindex="-1"
-                class="close pointer"
-                on-click="_dismissRespectfulTip"
-                >Not helpful</a
-              >
-            </div>
-          </div>
-        </template>
-      </template>
-      <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-      <gr-formatted-text
-        class="message"
-        content="[[comment.message]]"
-        no-trailing-margin="[[!comment.__draft]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <div class="action resolve hideOnPublished">
-          <label>
-            <input
-              type="checkbox"
-              id="resolvedCheckbox"
-              checked="[[resolved]]"
-              on-change="_handleToggleResolved"
-            />
-            Resolved
-          </label>
-        </div>
-        <template is="dom-if" if="[[draft]]">
-          <div class="rightActions">
-            <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-              <iron-icon
-                class="link-icon"
-                on-click="handleCopyLink"
-                class="copy"
-                title="Copy link to this comment"
-                icon="gr-icons:link"
-                role="button"
-                tabindex="0"
-              >
-              </iron-icon>
-            </template>
-            <gr-button
-              link=""
-              class="action cancel hideOnPublished"
-              on-click="_handleCancel"
-              >Cancel</gr-button
-            >
-            <gr-button
-              link=""
-              class="action discard hideOnPublished"
-              on-click="_handleDiscard"
-              >Discard</gr-button
-            >
-            <gr-button
-              link=""
-              class="action edit hideOnPublished"
-              on-click="_handleEdit"
-              >Edit</gr-button
-            >
-            <gr-button
-              link=""
-              disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-              class="action save hideOnPublished"
-              on-click="_handleSave"
-              >Save</gr-button
-            >
-          </div>
-        </template>
-      </div>
-      <div class="robotActions" hidden$="[[!_showRobotActions]]">
-        <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-        </template>
-        <template is="dom-if" if="[[isRobotComment]]">
-          <gr-endpoint-decorator name="robot-comment-controls">
-            <gr-endpoint-param name="comment" value="[[comment]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <gr-button
-            link=""
-            secondary=""
-            class="action show-fix"
-            hidden$="[[_hasNoFix(comment)]]"
-            on-click="_handleShowFix"
-          >
-            Show Fix
-          </gr-button>
-          <template is="dom-if" if="[[!_hasHumanReply]]">
-            <gr-button
-              link=""
-              class="action fix"
-              on-click="_handleFix"
-              disabled="[[robotButtonDisabled]]"
-            >
-              Please Fix
-            </gr-button>
-          </template>
-        </template>
-      </div>
-    </div>
-  </div>
-  <template is="dom-if" if="[[_enableOverlay]]">
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-      <gr-confirm-delete-comment-dialog
-        id="confirmDeleteComment"
-        on-confirm="_handleConfirmDeleteComment"
-        on-cancel="_handleCancelDeleteComment"
-      >
-      </gr-confirm-delete-comment-dialog>
-    </gr-overlay>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index e0bda4a..7066b64 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -14,1628 +14,601 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-comment';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
-import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {GrComment} from './gr-comment';
 import {
   queryAndAssert,
   stubRestApi,
   stubStorage,
-  spyStorage,
   query,
-  isVisible,
-  stubReporting,
+  pressKey,
+  listenOnce,
+  stubComments,
   mockPromise,
+  waitUntilCalled,
 } from '../../../test/test-utils';
 import {
   AccountId,
   EmailAddress,
-  FixId,
   NumericChangeId,
-  ParsedJSON,
   PatchSetNum,
-  RobotId,
-  RobotRunId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   createComment,
   createDraft,
   createFixSuggestionInfo,
+  createRobotComment,
 } from '../../../test/test-data-generators';
-import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {CreateFixCommentEvent} from '../../../types/events';
-import {DraftInfo, UIRobot} from '../../../utils/comment-util';
-import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {
+  CreateFixCommentEvent,
+  OpenFixPreviewEventDetail,
+} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
-  <gr-comment draft="true"></gr-comment>
-`);
+import {DraftInfo} from '../../../utils/comment-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Modifier} from '../../../utils/dom-util';
 
 suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element: GrComment;
+  let element: GrComment;
 
-    let openOverlaySpy: sinon.SinonSpy;
+  setup(() => {
+    element = fixtureFromElement('gr-comment').instantiate();
+    element.account = {
+      email: 'dhruvsri@google.com' as EmailAddress,
+      name: 'Dhruv Srivastava',
+      _account_id: 1083225 as AccountId,
+      avatars: [{url: 'abc', height: 32, width: 32}],
+      registered_on: '123' as Timestamp,
+    };
+    element.showPatchset = true;
+    element.getRandomInt = () => 1;
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      id: 'baf0414d_60047215' as UrlEncodedCommentId,
+      line: 5,
+      message: 'This is the test comment message.',
+      updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+    };
+  });
 
-    setup(() => {
-      stubRestApi('getAccount').returns(
-        Promise.resolve({
-          email: 'dhruvsri@google.com' as EmailAddress,
-          name: 'Dhruv Srivastava',
-          _account_id: 1083225 as AccountId,
-          avatars: [{url: 'abc', height: 32, width: 32}],
-          registered_on: '123' as Timestamp,
-        })
-      );
-      element = basicFixture.instantiate();
-      element.getRandomNum = () => 1;
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-      };
-
-      openOverlaySpy = sinon.spy(element, '_openOverlay');
-    });
-
-    teardown(() => {
-      openOverlaySpy.getCalls().forEach(call => {
-        call.args[0].remove();
-      });
-    });
-
-    test('renders', async () => {
-      await flush();
+  suite('DOM rendering', () => {
+    test('renders collapsed', async () => {
+      element.initiallyCollapsed = true;
+      await element.updateComplete;
       expect(element).shadowDom.to.equal(`
-        <div class="collapsed container" id="container">
+        <div class="container" id="container">
           <div class="header" id="header">
             <div class="headerLeft">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
               <gr-account-label deselected="" hidestatus=""></gr-account-label>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <gr-tooltip-content
-                class="draftTooltip"
-                has-tooltip=""
-                max-width="20em"
-                show-icon=""
-                title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
-              >
-                <span class="draftLabel">DRAFT</span>
-              </gr-tooltip-content>
             </div>
             <div class="headerMiddle">
               <span class="collapsedContent">
-                is this a crossover episode!?
+                This is the test comment message.
               </span>
             </div>
-            <div class="message runIdMessage" hidden="true">
-              <div class="runIdInformation">
-                <a class="robotRunLink">
-                  <span class="link robotRun">
-                    Run Details
-                  </span>
-                </a>
-              </div>
+            <span class="patchset-text">Patchset 1</span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Expand" class="show-hide">
+                <input checked="" class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-more"></iron-icon>
+              </label>
             </div>
-            <gr-button
-              aria-disabled="false"
-              class="action delete"
-              id="deleteBtn"
-              link=""
-              role="button"
-              tabindex="0"
-              title="Delete Comment"
-            >
-              <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
-            </gr-button>
-            <span class="patchset-text">Patchset</span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+          </div>
+          <div class="body"></div>
+        </div>
+      `);
+    });
+
+    test('renders expanded', async () => {
+      element.initiallyCollapsed = false;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(`
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label deselected="" hidestatus=""></gr-account-label>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
             <span class="separator"></span>
             <span class="date" tabindex="0">
               <gr-date-formatter withtooltip=""></gr-date-formatter>
             </span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
             <div class="show-hide" tabindex="0">
-              <label aria-label="Expand" class="show-hide">
-                <input checked="true" class="show-hide" type="checkbox">
-                <iron-icon id="icon"></iron-icon>
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
               </label>
             </div>
           </div>
           <div class="body">
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <gr-formatted-text class="message" notrailingmargin="">
-            </gr-formatted-text>
-            <div class="actions humanActions">
-              <div class="action hideOnPublished resolve">
-                <label>
-                  <input id="resolvedCheckbox" type="checkbox">
-                  Resolved
-                </label>
-              </div>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            </div>
-            <div class="robotActions">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            </div>
+            <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
           </div>
         </div>
-        <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
       `);
     });
 
-    test('renders editing:true', async () => {
+    test('renders expanded robot', async () => {
+      element.initiallyCollapsed = false;
+      element.comment = createRobotComment();
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(`
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <span class="robotName">robot-id-123</span>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <div class="robotId"></div>
+            <gr-formatted-text class="message" notrailingmargin=""></gr-formatted-text>
+            <div class="robotActions">
+              <iron-icon class="copy link-icon" icon="gr-icons:link" role="button" tabindex="0"
+                         title="Copy link to this comment">
+              </iron-icon>
+              <gr-endpoint-decorator name="robot-comment-controls">
+                <gr-endpoint-param name="comment"></gr-endpoint-param>
+              </gr-endpoint-decorator>
+              <gr-button aria-disabled="false" class="action show-fix" link="" role="button" secondary="" tabindex="0">
+                Show Fix
+              </gr-button>
+              <gr-button aria-disabled="false" class="action fix" link="" role="button" tabindex="0">
+                Please Fix
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      `);
+    });
+
+    test('renders expanded admin', async () => {
+      element.initiallyCollapsed = false;
+      element.isAdmin = true;
+      await element.updateComplete;
+      expect(queryAndAssert(element, 'gr-button.delete')).dom.to.equal(`
+        <gr-button
+          aria-disabled="false"
+          class="action delete"
+          id="deleteBtn"
+          link=""
+          role="button"
+          tabindex="0"
+          title="Delete Comment"
+        >
+          <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
+        </gr-button>
+      `);
+    });
+
+    test('renders draft', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(`
+        <div class="container draft" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
+              <gr-tooltip-content
+                class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+                title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+              >
+                <span class="draftLabel">DRAFT</span>
+              </gr-tooltip-content>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <gr-formatted-text class="message"></gr-formatted-text>
+            <div class="actions">
+              <div class="action resolve">
+                <label>
+                  <input checked="" id="resolvedCheckbox" type="checkbox">
+                  Resolved
+                </label>
+              </div>
+              <div class="rightActions">
+                <gr-button aria-disabled="false" class="action discard" link="" role="button" tabindex="0">
+                  Discard
+                </gr-button>
+                <gr-button aria-disabled="false" class="action edit" link="" role="button" tabindex="0">
+                  Edit
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
+    });
+
+    test('renders draft in editing mode', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
       element.editing = true;
-      await flush();
+      await element.updateComplete;
       expect(element).shadowDom.to.equal(`
-        <div class="collapsed container editing" id="container">
+        <div class="container draft" id="container">
           <div class="header" id="header">
             <div class="headerLeft">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <gr-account-label deselected="" hidestatus=""></gr-account-label>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+              <gr-account-label class="draft" deselected="" hidestatus=""></gr-account-label>
               <gr-tooltip-content
-                class="draftTooltip"
-                has-tooltip=""
-                max-width="20em"
-                show-icon=""
-                title="This draft is only visible to you. To publish drafts, click the 'Reply'or 'Start review' button at the top of the change or press the 'A' key."
+                class="draftTooltip" has-tooltip="" max-width="20em" show-icon=""
+                title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
               >
                 <span class="draftLabel">DRAFT</span>
               </gr-tooltip-content>
             </div>
-            <div class="headerMiddle">
-              <span class="collapsedContent">
-                is this a crossover episode!?
-              </span>
-            </div>
-            <div class="message runIdMessage" hidden="true">
-              <div class="runIdInformation">
-                <a class="robotRunLink">
-                  <span class="link robotRun">
-                    Run Details
-                  </span>
-                </a>
-              </div>
-            </div>
-            <gr-button
-              aria-disabled="false"
-              class="action delete"
-              id="deleteBtn"
-              link=""
-              role="button"
-              tabindex="0"
-              title="Delete Comment"
-            >
-              <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
-            </gr-button>
-            <span class="patchset-text">Patchset</span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
             <span class="separator"></span>
             <span class="date" tabindex="0">
               <gr-date-formatter withtooltip=""></gr-date-formatter>
             </span>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
             <div class="show-hide" tabindex="0">
-              <label aria-label="Expand" class="show-hide">
-                <input checked="true" class="show-hide" type="checkbox">
-                <iron-icon id="icon"></iron-icon>
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox">
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
               </label>
             </div>
           </div>
           <div class="body">
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <gr-textarea autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4">
+            <gr-textarea
+              autocomplete="on" class="code editMessage" code="" id="editTextarea" rows="4"
+              text="This is the test comment message."
+            >
             </gr-textarea>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            <gr-formatted-text class="message" notrailingmargin="">
-            </gr-formatted-text>
-            <div class="actions humanActions">
-              <div class="action hideOnPublished resolve">
+            <div class="actions">
+              <div class="action resolve">
                 <label>
-                  <input id="resolvedCheckbox" type="checkbox">
+                  <input checked="" id="resolvedCheckbox" type="checkbox">
                   Resolved
                 </label>
               </div>
-            <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-            </div>
-            <div class="robotActions">
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-              <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
+              <div class="rightActions">
+                <gr-button aria-disabled="false" class="action cancel" link="" role="button" tabindex="0">
+                  Cancel
+                </gr-button>
+                <gr-button aria-disabled="false" class="action save" link="" role="button" tabindex="0">
+                  Save
+                </gr-button>
+              </div>
             </div>
           </div>
         </div>
-        <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
       `);
     });
-
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      flush();
-      const dateEl = queryAndAssert(element, '.date');
-      assert.ok(dateEl);
-      tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {
-        side: element.side,
-        number: element.comment!.line,
-      });
-    });
-
-    test('message is not retrieved from storage when missing path', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
-    });
-
-    test('message is not retrieved from storage when message present', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        message: 'This is a message',
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
-    });
-
-    test('message is retrieved from storage for drafts in edit', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isTrue(storageStub.called);
-    });
-
-    test('comment message sets messageText only when empty', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = '';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
-
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
-
-    test('comment message sets messageText when not edited', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = 'Some text';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: false,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
-
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1 as PatchSetNum;
-      assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1 as PatchSetNum);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-    });
-
-    suite('while editing', () => {
-      let handleCancelStub: sinon.SinonStub;
-      let handleSaveStub: sinon.SinonStub;
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        handleCancelStub = sinon.stub(element, '_handleCancel');
-        handleSaveStub = sinon.stub(element, '_handleSave');
-        flush();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-          assert.isTrue(handleCancelStub.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('meta+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-          assert.isFalse(handleSaveStub.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-        assert.isFalse(handleCancelStub.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('meta+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('ctrl+s saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-        assert.isTrue(handleSaveStub.called);
-      });
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-    });
-
-    test('delete comment', async () => {
-      const stub = stubRestApi('deleteComment').returns(
-        Promise.resolve(createComment())
-      );
-      const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
-      element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._isAdmin = true;
-      assert.isTrue(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-      tap(queryAndAssert(element, '.action.delete'));
-      await flush();
-      await openSpy.lastCall.returnValue;
-      const dialog = element.confirmDeleteOverlay?.querySelector(
-        '#confirmDeleteComment'
-      ) as GrConfirmDeleteCommentDialog;
-      dialog.message = 'removal reason';
-      element._handleConfirmDeleteComment();
-      assert.isTrue(
-        stub.calledWith(
-          42 as NumericChangeId,
-          1 as PatchSetNum,
-          'baf0414d_60047215' as UrlEncodedCommentId,
-          'removal reason'
-        )
-      );
-    });
-
-    suite('draft update reporting', () => {
-      let endStub: SinonStubbedMember<() => Timer>;
-      let getTimerStub: sinon.SinonStub;
-      const mockEvent = {...new Event('click'), preventDefault() {}};
-
-      setup(() => {
-        sinon.stub(element, 'save').returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        const mockTimer = new MockTimer();
-        mockTimer.end = endStub;
-        getTimerStub = stubReporting('getTimer').returns(mockTimer);
-      });
-
-      test('create', async () => {
-        element.patchNum = 1 as PatchSetNum;
-        element.comment = {};
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        await element._handleSave(mockEvent);
-        await flush();
-        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
-        const spanName = queryAndAssert<HTMLSpanElement>(
-          grAccountLabel,
-          'span.name'
-        );
-        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
-        assert.isTrue(endStub.calledOnce);
-        assert.isTrue(getTimerStub.calledOnce);
-        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-      });
-
-      test('update', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        element.comment = createDraft();
-        sinon.stub(element, '_fireDiscard');
-        sinon.stub(element, '_eraseDraftCommentFromStorage');
-        sinon
-          .stub(element, '_deleteDraft')
-          .returns(Promise.resolve(new Response()));
-        return element._discardDraft().then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_fireEdit');
-      element.draft = true;
-      flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_eraseDraftCommentFromStorage');
-      sinon.stub(element, '_fireDiscard');
-      sinon
-        .stub(element, '_deleteDraft')
-        .returns(Promise.resolve(new Response()));
-      element.draft = true;
-      element.comment = createDraft();
-      flush();
-      tap(queryAndAssert(element, '.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('failed save draft request', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({...new Response(), ok: false})
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
-
-    test('failed save draft request with promise failure', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.reject(new Error())
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
   });
 
-  suite('gr-comment draft tests', () => {
-    let element: GrComment;
+  test('clicking on date link fires event', async () => {
+    const stub = sinon.stub();
+    element.addEventListener('comment-anchor-tap', stub);
+    await element.updateComplete;
 
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
-      stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({
-          ...new Response(),
-          ok: true,
-          text() {
-            return Promise.resolve(
-              ")]}'\n{" +
-                '"id": "baf0414d_40572e03",' +
-                '"path": "/path/to/file",' +
-                '"line": 5,' +
-                '"updated": "2015-12-08 21:52:36.177000000",' +
-                '"message": "saved!",' +
-                '"side": "REVISION",' +
-                '"unresolved": false,' +
-                '"patch_set": 1' +
-                '}'
-            );
-          },
-        })
-      );
-      stubRestApi('removeChangeReviewer').returns(
-        Promise.resolve({...new Response(), ok: true})
-      );
-      element = draftFixture.instantiate() as GrComment;
-      stubStorage('getDraftComment').returns(null);
+    const dateEl = queryAndAssert(element, '.date');
+    tap(dateEl);
+
+    assert.isTrue(stub.called);
+    assert.deepEqual(stub.lastCall.args[0].detail, {
+      side: 'REVISION',
+      number: element.comment!.line,
+    });
+  });
+
+  test('comment message sets messageText only when empty', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = '';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
+
+    element.comment!.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
+
+  test('comment message sets messageText when not edited', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = 'Some text';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
+
+    element.comment!.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
+
+  test('delete comment', async () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.isAdmin = true;
+    await element.updateComplete;
+
+    const deleteButton = queryAndAssert(element, '.action.delete');
+    tap(deleteButton);
+    await element.updateComplete;
+
+    assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+    const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
+      element.confirmDeleteOverlay,
+      '#confirmDeleteComment'
+    );
+    dialog.message = 'removal reason';
+    await element.updateComplete;
+
+    const stub = stubRestApi('deleteComment').returns(
+      Promise.resolve(createComment())
+    );
+    element.handleConfirmDeleteComment();
+    assert.isTrue(
+      stub.calledWith(
+        42 as NumericChangeId,
+        1 as PatchSetNum,
+        'baf0414d_60047215' as UrlEncodedCommentId,
+        'removal reason'
+      )
+    );
+  });
+
+  suite('gr-comment draft tests', () => {
+    setup(async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.editing = false;
       element.comment = {
         ...createComment(),
         __draft: true,
-        __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
-        id: undefined,
       };
     });
 
-    test('button visibility states', async () => {
-      element.showActions = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+    test('isSaveDisabled', async () => {
+      element.saving = false;
+      element.unresolved = true;
+      element.comment = {...createComment(), unresolved: true};
+      element.messageText = 'asdf';
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.showActions = true;
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.messageText = '';
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
 
-      element.draft = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.unresolved = false;
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.editing = true;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.draft = false;
-      element.editing = false;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      element.draft = true;
-      element.editing = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // Delete button is not hidden by default
-      assert.isFalse(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      await flush();
-      assert.isTrue(
-        queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
-      );
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      await flush();
-      assert.notEqual(
-        getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
-        'none'
-      );
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-    });
-
-    test('collapsible drafts', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      await flush();
-      assert.isTrue(fireEditStub.called);
-      assert.isFalse(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-textarea')),
-        'textarea is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      tap(element.$.header);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-    });
-
-    test('robot comment layout', async () => {
-      const comment = {
-        robot_id: 'happy_robot_id' as RobotId,
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-          display_name: 'Display name Robot',
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush;
-      let runIdMessage;
-      runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
-      assert.isFalse((runIdMessage as HTMLElement).hidden);
-
-      const runDetailsLink = queryAndAssert(
-        element,
-        '.robotRunLink'
-      ) as HTMLAnchorElement;
-      assert.isTrue(
-        runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
-      );
-
-      const robotServiceName = queryAndAssert(element, '.robotName');
-      assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
-
-      const authorName = queryAndAssert(element, '.robotId');
-      assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
-
-      element.collapsed = true;
-      await flush();
-      runIdMessage = queryAndAssert(element, '.runIdMessage');
-      assert.isTrue((runIdMessage as HTMLDivElement).hidden);
-    });
-
-    test('author name fallback to email', async () => {
-      const comment = {
-        url: '/robot/comment',
-        author: {
-          email: 'test@test.com' as EmailAddress,
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush();
-      const authorName = queryAndAssert(
-        queryAndAssert(element, 'gr-account-label'),
-        'span.name'
-      ) as HTMLSpanElement;
-      assert.equal(authorName.innerText.trim(), 'test@test.com');
-    });
-
-    test('patchset level comment', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const comment = {
-        ...element.comment,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        line: undefined,
-        range: undefined,
-      };
-      element.comment = comment;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element._messageText = 'hello world';
-      const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
-      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
-      element._handleSave(mockEvent);
-      await flush();
-      assert.isTrue(eraseMessageDraftSpy.called);
-    });
-
-    test('draft creation/cancellation', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isFalse(element.editing);
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element.comment!.message = '';
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      // Save should be disabled on an empty message.
-      let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          promise.resolve();
-        }
-      });
-      tap(queryAndAssert(element, '.cancel'));
-      await flush();
-      element._messageText = '';
-      element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-      await promise;
-    });
-
-    test('draft discard removes message from storage', async () => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        promise.resolve();
-      });
-      element._handleDiscard({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await promise;
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
-      stubRestApi('getResponseObject').returns(
-        Promise.resolve({...(createDraft() as ParsedJSON)})
-      );
-      const saveDraftStub = sinon
-        .stub(element, '_saveDraft')
-        .returns(Promise.resolve({...new Response(), ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        saveDraftStub.restore();
-        sinon
-          .stub(element, '_saveDraft')
-          .returns(Promise.resolve({...new Response(), ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-        element._computeSaveDisabled('test', msgComment, false),
-        false
-      );
-      assert.equal(
-        element._computeSaveDisabled('test2', msgComment, false),
-        false
-      );
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      element.saving = true;
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
     });
 
     test('ctrl+s saves comment', async () => {
-      const promise = mockPromise();
-      const stub = sinon.stub(element, 'save').callsFake(() => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        promise.resolve();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
+      const spy = sinon.stub(element, 'save');
+      element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(
-        element.textarea!.$.textarea.textarea,
-        83,
-        'ctrl',
-        's'
-      );
-      await promise;
+      await element.updateComplete;
+      pressKey(element.textarea!.$.textarea.textarea, 's', Modifier.CTRL_KEY);
+      assert.isTrue(spy.called);
     });
 
-    test('draft saving/editing', async () => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
+    test('save', async () => {
+      const savePromise = mockPromise<void>();
+      const stub = stubComments('saveDraft').returns(savePromise);
 
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      tickAndFlush(1);
-      element._messageText = 'good news, everyone!';
-      tickAndFlush(1);
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
-      assert.isTrue(dispatchEventStub.calledTwice);
+      element.comment = createDraft();
+      element.editing = true;
+      const textToSave = 'something, not important';
+      element.messageText = textToSave;
+      element.unresolved = true;
+      await element.updateComplete;
 
-      element._messageText = 'good news, everyone!';
-      await flush();
-      assert.isTrue(dispatchEventStub.calledTwice);
+      element.save();
 
-      tap(queryAndAssert(element, '.save'));
+      await element.updateComplete;
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, textToSave);
+      assert.equal(stub.lastCall.firstArg.unresolved, true);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
 
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when creating draft.'
-      );
+      savePromise.resolve();
+      await element.updateComplete;
 
-      let draft = await element._xhrPromise!;
-      const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
-        comment: DraftInfo;
-      }>;
-      assert.equal(evt.type, 'comment-save');
-
-      const expectedDetail = {
-        comment: {
-          ...createComment(),
-          __draft: true,
-          __draftID: 'temp_draft_id',
-          id: 'baf0414d_40572e03' as UrlEncodedCommentId,
-          line: 5,
-          message: 'saved!',
-          path: '/path/to/file',
-          updated: '2015-12-08 21:52:36.177000000' as Timestamp,
-        },
-        patchNum: 1 as PatchSetNum,
-      };
-
-      assert.deepEqual(evt.detail, expectedDetail);
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done creating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
       assert.isFalse(element.editing);
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.calledTwice);
-      element._messageText =
-        'You’ll be delivering a package to Chapek 9, ' +
-        'a world where humans are killed on sight.';
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when updating draft.'
+      assert.isFalse(element.saving);
+    });
+
+    test('save failed', async () => {
+      stubComments('saveDraft').returns(
+        Promise.reject(new Error('saving failed'))
       );
-      draft = await element._xhrPromise!;
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done updating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
+
+      element.comment = createDraft();
+      element.editing = true;
+      element.messageText = 'something, not important';
+      await element.updateComplete;
+
+      element.save();
+      await element.updateComplete;
+
+      assert.isTrue(element.unableToSave);
+      assert.isTrue(element.editing);
+      assert.isFalse(element.saving);
+    });
+
+    test('discard', async () => {
+      const discardPromise = mockPromise<void>();
+      const stub = stubComments('discardDraft').returns(discardPromise);
+
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.discard();
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'discardDraft()');
+      assert.equal(stub.lastCall.firstArg, element.comment.id);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      discardPromise.resolve();
+      await element.updateComplete;
+
       assert.isFalse(element.editing);
-      dispatchEventStub.restore();
+      assert.isFalse(element.saving);
     });
 
-    test('draft prevent save when disabled', async () => {
-      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
-      sinon.stub(element, '_fireEdit');
-      element.showActions = true;
-      element.draft = true;
-      await flush();
-      tap(element.$.header);
-      tap(queryAndAssert(element, '.edit'));
-      element._messageText = 'good news, everyone!';
-      await flush();
-
-      element.disabled = true;
-      tap(queryAndAssert(element, '.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', async () => {
-      const save = sinon.stub(element, 'save');
-      const promise = mockPromise();
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        promise.resolve();
-      });
-      tap(queryAndAssert(element, '.resolve input'));
-      await promise;
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sinon.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sinon.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isFalse(save.called);
-      tap(element.$.resolvedCheckbox);
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sinon.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', async () => {
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
+    test('unresolved comment state indicated by checkbox', async () => {
+      const saveStub = sinon.stub(element, 'save');
+      element.comment = {
+        ...createComment(),
+        __draft: true,
+        unresolved: false,
       };
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      const eraseStub = stubStorage('eraseDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      tickAndFlush(1);
+      await element.updateComplete;
 
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel();
-      await flush();
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '#resolvedCheckbox'
+      );
+      assert.isTrue(checkbox.checked);
+
+      tap(checkbox);
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
+      assert.isFalse(checkbox.checked);
+
+      assert.isTrue(saveStub.called);
     });
 
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      flush();
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel();
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
+    test('saving empty text calls discard()', async () => {
+      const discardStub = sinon.stub(element, 'discard');
       element.comment = {
         ...createComment(),
         id: 'foo' as UrlEncodedCommentId,
         message: 'test',
+        __draft: true,
       };
-      element._messageText = '';
-      const discardStub = sinon.stub(element, '_discardDraft');
+      element.editing = true;
+      await element.updateComplete;
+
+      element.messageText = '';
+      await element.updateComplete;
 
       element.save();
       assert.isTrue(discardStub.called);
     });
 
-    test('_handleFix fires create-fix event', async () => {
-      const promise = mockPromise();
-      element.addEventListener(
-        'create-fix-comment',
-        (e: CreateFixCommentEvent) => {
-          assert.deepEqual(e.detail, element._getEventPayload());
-          promise.resolve();
-        }
+    test('handleFix fires create-fix event', async () => {
+      const listener = listenOnce<CreateFixCommentEvent>(
+        element,
+        'create-fix-comment'
       );
-      element.isRobotComment = true;
+      element.comment = createRobotComment();
       element.comments = [element.comment!];
-      await flush();
+      await element.updateComplete;
 
       tap(queryAndAssert(element, '.fix'));
-      await promise;
+
+      const e = await listener;
+      assert.deepEqual(e.detail, element.getEventPayload());
     });
 
-    test('do not show Please Fix button if human reply exists', () => {
+    test('do not show Please Fix button if human reply exists', async () => {
+      element.initiallyCollapsed = false;
+      const robotComment = createRobotComment();
+      element.comment = robotComment;
+      await element.updateComplete;
+
+      let actions = query(element, '.robotActions gr-button.fix');
+      assert.isOk(actions);
+
       element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: new Date(),
-          path: 'Documentation/config-gerrit.txt',
-          side: CommentSide.REVISION,
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
+        robotComment,
+        {...createComment(), in_reply_to: robotComment.id},
       ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNull(
-        element.shadowRoot?.querySelector('robotActions gr-button')
+      await element.updateComplete;
+      actions = query(element, '.robotActions gr-button.fix');
+      assert.isNotOk(actions);
+    });
+
+    test('handleShowFix fires open-fix-preview event', async () => {
+      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
+        element,
+        'open-fix-preview'
       );
-    });
-
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      queryAndAssert(element, '.robotActions gr-button');
-    });
-
-    test('_handleShowFix fires open-fix-preview event', async () => {
-      const promise = mockPromise();
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        promise.resolve();
-      });
       element.comment = {
-        ...createComment(),
+        ...createRobotComment(),
         fix_suggestions: [{...createFixSuggestionInfo()}],
       };
-      element.isRobotComment = true;
-      await flush();
+      await element.updateComplete;
 
       tap(queryAndAssert(element, '.show-fix'));
-      await promise;
+
+      const e = await listener;
+      assert.deepEqual(e.detail, element.getEventPayload());
     });
   });
 
   suite('respectful tips', () => {
-    let element: GrComment;
-
     let clock: sinon.SinonFakeTimers;
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    setup(async () => {
       clock = sinon.useFakeTimers();
     });
 
@@ -1645,81 +618,81 @@
     });
 
     test('show tip when no cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 0;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+      queryAndAssert(element, '.respectfulReviewTip');
     });
 
     test('add 14-day delays once dismissed', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 0;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isTrue(respectfulSetStub.called);
       assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+      const closeLink = queryAndAssert(element, '.respectfulReviewTip a.close');
+      tap(closeLink);
+      await element.updateComplete;
 
-      tap(queryAndAssert(element, '.respectfulReviewTip .close'));
-      flush();
       assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
     });
 
     test('do not show tip when fall out of probability', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 3;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 2;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isFalse(respectfulSetStub.called);
       assert.isNotOk(query(element, '.respectfulReviewTip'));
     });
 
     test('show tip when editing changed to true', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: false};
-      await flush();
+      element.editing = false;
+      element.getRandomInt = () => 0;
+      element.comment = createComment();
+      await element.updateComplete;
+
       assert.isFalse(respectfulGetStub.called);
       assert.isFalse(respectfulSetStub.called);
       assert.isNotOk(query(element, '.respectfulReviewTip'));
 
       element.editing = true;
-      await flush();
+      await element.updateComplete;
       assert.isTrue(respectfulGetStub.called);
       assert.isTrue(respectfulSetStub.called);
       assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
     });
 
     test('no tip when cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
       const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
       const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
       respectfulGetStub.returns({updated: 0});
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
+      element.editing = true;
+      element.getRandomInt = () => 0;
+      element.comment = createDraft();
+      await element.updateComplete;
+
       assert.isTrue(respectfulGetStub.called);
       assert.isFalse(respectfulSetStub.called);
       assert.isNotOk(query(element, '.respectfulReviewTip'));
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 6180f35..a39d033 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -78,7 +78,7 @@
   @property({type: Number})
   initialCount = 75;
 
-  @property({type: Object})
+  @property({type: Array})
   items?: DropdownItem[];
 
   @property({type: String})
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
index adb54ab..37b14b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-impl.ts
@@ -156,6 +156,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {ErrorCallback} from '../../../api/rest';
 import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
+import {addDraftProp, DraftInfo} from '../../../utils/comment-util';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -2276,45 +2277,16 @@
    * is no logged in user, the request is not made and the promise yields an
    * empty object.
    */
-  getDiffDrafts(
+  async getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: BasePatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return {};
-      }
-      if (!basePatchNum && !patchNum && !path) {
-        return this._getDiffComments(changeNum, '/drafts', {
-          'enable-context': true,
-          'context-padding': 3,
-        });
-      }
-      return this._getDiffComments(
-        changeNum,
-        '/drafts',
-        {
-          'enable-context': true,
-          'context-padding': 3,
-        },
-        basePatchNum,
-        patchNum,
-        path
-      );
+  ): Promise<{[path: string]: DraftInfo[]} | undefined> {
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) return {};
+    const comments = await this._getDiffComments(changeNum, '/drafts', {
+      'enable-context': true,
+      'context-padding': 3,
     });
+    return addDraftProp(comments);
   }
 
   _setRange(comments: CommentInfo[], comment: CommentInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 8b44242..b602a87 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -424,6 +424,9 @@
   }
 
   _handleTextChanged(text: string) {
+    // This is a bit redundant, because the `text` property has `notify:true`,
+    // so whenever the `text` changes the component fires two identical events
+    // `text-changed` and `value-changed`.
     this.dispatchEvent(
       new CustomEvent('value-changed', {detail: {value: text}})
     );
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 8b16b1f..c43ed4e 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -58,8 +58,11 @@
       return new ChangeService(ctx.restApiService!);
     },
     commentsService: (ctx: Partial<AppContext>) => {
-      assertIsDefined(ctx.restApiService, 'restApiService');
-      return new CommentsService(ctx.restApiService!);
+      const restApi = ctx.restApiService;
+      const reporting = ctx.reportingService;
+      assertIsDefined(restApi, 'restApiService');
+      assertIsDefined(reporting, 'reportingService');
+      return new CommentsService(restApi, reporting);
     },
     checksModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index ad7865b39..62aecbc 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -16,22 +16,29 @@
  */
 
 import {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {
   CommentInfo,
   PathToCommentsInfoMap,
   RobotCommentInfo,
+  UrlEncodedCommentId,
 } from '../../types/common';
-import {addPath, DraftInfo} from '../../utils/comment-util';
+import {addPath, DraftInfo, isDraft, isUnsaved} from '../../utils/comment-util';
+import {deepEqual} from '../../utils/deep-util';
+import {select} from '../../utils/observable-util';
 
 interface CommentState {
   /** undefined means 'still loading' */
   comments?: PathToCommentsInfoMap;
   /** undefined means 'still loading' */
   robotComments?: {[path: string]: RobotCommentInfo[]};
+  // All drafts are DraftInfo objects and have __draft = true set.
+  // Drafts have an id and are known to the backend. Unsaved drafts
+  // (see UnsavedInfo) do NOT belong in the application model.
   /** undefined means 'still loading' */
   drafts?: {[path: string]: DraftInfo[]};
+  // Ported comments only affect `CommentThread` properties, not individual
+  // comments.
   /** undefined means 'still loading' */
   portedComments?: PathToCommentsInfoMap;
   /** undefined means 'still loading' */
@@ -73,55 +80,56 @@
   privateState$.next(state);
 }
 
-export const commentsLoading$ = commentState$.pipe(
-  map(
-    commentState =>
-      commentState.comments === undefined ||
-      commentState.robotComments === undefined ||
-      commentState.drafts === undefined
-  ),
-  distinctUntilChanged()
+export const commentsLoading$ = select(
+  commentState$,
+  commentState =>
+    commentState.comments === undefined ||
+    commentState.robotComments === undefined ||
+    commentState.drafts === undefined
 );
 
-export const comments$ = commentState$.pipe(
-  map(commentState => commentState.comments),
-  distinctUntilChanged()
+export const comments$ = select(
+  commentState$,
+  commentState => commentState.comments
 );
 
-export const drafts$ = commentState$.pipe(
-  map(commentState => commentState.drafts),
-  distinctUntilChanged()
+export const drafts$ = select(
+  commentState$,
+  commentState => commentState.drafts
 );
 
-export const portedComments$ = commentState$.pipe(
-  map(commentState => commentState.portedComments),
-  distinctUntilChanged()
+export const portedComments$ = select(
+  commentState$,
+  commentState => commentState.portedComments
 );
 
-export const discardedDrafts$ = commentState$.pipe(
-  map(commentState => commentState.discardedDrafts),
-  distinctUntilChanged()
+export const discardedDrafts$ = select(
+  commentState$,
+  commentState => commentState.discardedDrafts
 );
 
 // Emits a new value even if only a single draft is changed. Components should
 // aim to subsribe to something more specific.
-export const changeComments$ = commentState$.pipe(
-  map(
-    commentState =>
-      new ChangeComments(
-        commentState.comments,
-        commentState.robotComments,
-        commentState.drafts,
-        commentState.portedComments,
-        commentState.portedDrafts
-      )
-  )
+export const changeComments$ = select(
+  commentState$,
+  commentState =>
+    new ChangeComments(
+      commentState.comments,
+      commentState.robotComments,
+      commentState.drafts,
+      commentState.portedComments,
+      commentState.portedDrafts
+    )
 );
 
-export const threads$ = changeComments$.pipe(
-  map(changeComments => changeComments.getAllThreadsForChange())
+export const threads$ = select(changeComments$, changeComments =>
+  changeComments.getAllThreadsForChange()
 );
 
+export function thread$(id: UrlEncodedCommentId) {
+  return select(threads$, threads => threads.find(t => t.rootId === id));
+}
+
 function publishState(state: CommentState) {
   privateState$.next(state);
 }
@@ -135,6 +143,7 @@
   [path: string]: CommentInfo[];
 }) {
   const nextState = {...privateState$.getValue()};
+  if (deepEqual(comments, nextState.comments)) return;
   nextState.comments = addPath(comments) || {};
   publishState(nextState);
 }
@@ -143,13 +152,15 @@
   [path: string]: RobotCommentInfo[];
 }) {
   const nextState = {...privateState$.getValue()};
+  if (deepEqual(robotComments, nextState.robotComments)) return;
   nextState.robotComments = addPath(robotComments) || {};
   publishState(nextState);
 }
 
 export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
   const nextState = {...privateState$.getValue()};
-  nextState.drafts = addPath(drafts) || {};
+  if (deepEqual(drafts, nextState.drafts)) return;
+  nextState.drafts = addPath(drafts);
   publishState(nextState);
 }
 
@@ -157,23 +168,25 @@
   portedComments?: PathToCommentsInfoMap
 ) {
   const nextState = {...privateState$.getValue()};
+  if (deepEqual(portedComments, nextState.portedComments)) return;
   nextState.portedComments = portedComments || {};
   publishState(nextState);
 }
 
 export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
   const nextState = {...privateState$.getValue()};
+  if (deepEqual(portedDrafts, nextState.portedDrafts)) return;
   nextState.portedDrafts = portedDrafts || {};
   publishState(nextState);
 }
 
-export function updateStateAddDiscardedDraft(draft: DraftInfo) {
+export function updateStateSetDiscardedDraft(draft: DraftInfo) {
   const nextState = {...privateState$.getValue()};
   nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
   publishState(nextState);
 }
 
-export function updateStateUndoDiscardedDraft(draftID?: string) {
+export function updateStateDeleteDiscardedDraft(draftID?: string) {
   const nextState = {...privateState$.getValue()};
   const drafts = [...nextState.discardedDrafts];
   const index = drafts.findIndex(d => d.id === draftID);
@@ -185,18 +198,18 @@
   publishState(nextState);
 }
 
-export function updateStateAddDraft(draft: DraftInfo) {
+/** Adds or updates a draft. */
+export function updateStateSetDraft(draft: DraftInfo) {
   const nextState = {...privateState$.getValue()};
   if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
   if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
   else drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
+  const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
   if (index !== -1) {
     drafts[draft.path][index] = draft;
   } else {
@@ -205,38 +218,20 @@
   publishState(nextState);
 }
 
-export function updateStateUpdateDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path])
-    throw new Error('draft: trying to edit non-existent draft');
-  drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  drafts[draft.path][index] = draft;
-  publishState(nextState);
-}
-
 export function updateStateDeleteDraft(draft: DraftInfo) {
   const nextState = {...privateState$.getValue()};
   if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
   const index = (drafts[draft.path] || []).findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
+    d => d.id && d.id === draft.id
   );
   if (index === -1) return;
   const discardedDraft = drafts[draft.path][index];
   drafts[draft.path] = [...drafts[draft.path]];
   drafts[draft.path].splice(index, 1);
   publishState(nextState);
-  updateStateAddDiscardedDraft(discardedDraft);
+  updateStateSetDiscardedDraft(discardedDraft);
 }
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
index c888cd5..ac32258 100644
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ b/polygerrit-ui/app/services/comments/comments-service.ts
@@ -15,31 +15,58 @@
  * limitations under the License.
  */
 import {combineLatest, Subscription} from 'rxjs';
-import {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
-import {DraftInfo, UIDraft} from '../../utils/comment-util';
-import {fireAlert} from '../../utils/event-util';
+import {
+  CommentBasics,
+  CommentInfo,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionId,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {
+  reportingDetails,
+  DraftInfo,
+  UnsavedInfo,
+} from '../../utils/comment-util';
+import {fire, fireAlert, fireEvent} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../gr-rest-api/gr-rest-api';
 import {
-  updateStateAddDraft,
+  updateStateSetDraft,
   updateStateDeleteDraft,
-  updateStateUpdateDraft,
   updateStateComments,
   updateStateRobotComments,
   updateStateDrafts,
   updateStatePortedComments,
   updateStatePortedDrafts,
-  updateStateUndoDiscardedDraft,
+  updateStateDeleteDiscardedDraft,
   discardedDrafts$,
   updateStateReset,
+  drafts$,
 } from './comments-model';
 import {changeNum$, currentPatchNum$} from '../change/change-model';
-
-import {routerChangeNum$} from '../router/router-model';
 import {Finalizable} from '../registry';
+import {routerChangeNum$} from '../router/router-model';
+import {Interaction} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {pluralize} from '../../utils/string-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+
+const TOAST_DEBOUNCE_INTERVAL = 200;
+
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+  if (requestFailed) {
+    return 'Unable to save draft';
+  }
+  if (numPending === 0) {
+    return 'All changes saved';
+  }
+  return `Saving ${pluralize(numPending, 'draft')}...`;
+}
 
 export class CommentsService implements Finalizable {
-  private discardedDrafts?: UIDraft[] = [];
+  private numPendingDraftRequests = 0;
 
   private changeNum?: NumericChangeId;
 
@@ -49,11 +76,22 @@
 
   private readonly subscriptions: Subscription[] = [];
 
-  constructor(readonly restApiService: RestApiService) {
+  private drafts: {[path: string]: DraftInfo[]} = {};
+
+  private draftToastTask?: DelayedTask;
+
+  private discardedDrafts: DraftInfo[] = [];
+
+  constructor(
+    readonly restApiService: RestApiService,
+    readonly reporting: ReportingService
+  ) {
     this.subscriptions.push(
-      discardedDrafts$.subscribe(
-        discardedDrafts => (this.discardedDrafts = discardedDrafts)
-      )
+      discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+    );
+    this.subscriptions.push(drafts$.subscribe(x => (this.drafts = x ?? {})));
+    this.subscriptions.push(
+      currentPatchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
       routerChangeNum$.subscribe(changeNum => {
@@ -137,44 +175,141 @@
       .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
   }
 
-  restoreDraft(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draftID: string
-  ) {
-    const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
-    if (!draft) throw new Error('discarded draft not found');
-    // delete draft ID since we want to treat this as a new draft creation
-    delete draft.id;
-    this.restApiService
-      .saveDiffDraft(changeNum, patchNum, draft)
-      .then(result => {
-        if (!result.ok) {
-          fireAlert(document, 'Unable to restore draft');
-          return;
-        }
-        this.restApiService.getResponseObject(result).then(obj => {
-          const resComment = obj as unknown as DraftInfo;
-          resComment.patch_set = draft.patch_set;
-          updateStateAddDraft(resComment);
-          updateStateUndoDiscardedDraft(draftID);
-        });
-      });
+  async restoreDraft(id: UrlEncodedCommentId) {
+    const found = this.discardedDrafts?.find(d => d.id === id);
+    if (!found) throw new Error('discarded draft not found');
+    const newDraft = {
+      ...found,
+      id: undefined,
+      updated: undefined,
+      __draft: undefined,
+      __unsaved: true,
+    };
+    await this.saveDraft(newDraft);
+    updateStateDeleteDiscardedDraft(id);
   }
 
-  addDraft(draft: DraftInfo) {
-    updateStateAddDraft(draft);
+  /**
+   * Saves a new or updates an existing draft.
+   * The model will only be updated when a successful response comes back.
+   */
+  async saveDraft(draft: DraftInfo | UnsavedInfo, showToast = true) {
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.SAVE_COMMENT, draft);
+    if (showToast) this.showStartRequest();
+    const result = await this.restApiService.saveDiffDraft(
+      changeNum,
+      draft.patch_set,
+      draft
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      if (showToast) this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to save draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    const obj = await this.restApiService.getResponseObject(result);
+    const savedComment = obj as unknown as CommentInfo;
+    const updatedDraft = {
+      ...draft,
+      id: savedComment.id,
+      updated: savedComment.updated,
+      __draft: true,
+      __unsaved: undefined,
+    };
+    if (showToast) this.showEndRequest();
+    updateStateSetDraft(updatedDraft);
+    this.report(Interaction.COMMENT_SAVED, updatedDraft);
   }
 
-  cancelDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
+  async discardDraft(draftId: UrlEncodedCommentId) {
+    const draft = this.lookupDraft(draftId);
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft, `draft not found by id ${draftId}`);
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
 
-  editDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  deleteDraft(draft: DraftInfo) {
+    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.DISCARD_COMMENT, draft);
+    this.showStartRequest();
+    const result = await this.restApiService.deleteDiffDraft(
+      changeNum,
+      draft.patch_set,
+      {id: draft.id}
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to discard draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    this.showEndRequest();
     updateStateDeleteDraft(draft);
+    // We don't store empty discarded drafts and don't need an UNDO then.
+    if (draft.message?.trim()) {
+      fire(document, 'show-alert', {
+        message: 'Draft Discarded',
+        action: 'Undo',
+        callback: () => this.restoreDraft(draft.id),
+      });
+    }
+    this.report(Interaction.COMMENT_DISCARDED, draft);
+  }
+
+  private report(interaction: Interaction, comment: CommentBasics) {
+    const details = reportingDetails(comment);
+    this.reporting.reportInteraction(interaction, details);
+  }
+
+  private showStartRequest() {
+    this.numPendingDraftRequests += 1;
+    this.updateRequestToast();
+  }
+
+  private showEndRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast();
+  }
+
+  private handleFailedDraftRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast(/* requestFailed=*/ true);
+  }
+
+  private updateRequestToast(requestFailed?: boolean) {
+    if (this.numPendingDraftRequests === 0 && !requestFailed) {
+      fireEvent(document, 'hide-alert');
+      return;
+    }
+    const message = getSavingMessage(
+      this.numPendingDraftRequests,
+      requestFailed
+    );
+    this.draftToastTask = debounce(
+      this.draftToastTask,
+      () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        fireAlert(document.body, message);
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
+  }
+
+  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+    return Object.values(this.drafts)
+      .flat()
+      .find(d => d.id === id);
   }
 }
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
index 5fe859f..91ac37c 100644
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ b/polygerrit-ui/app/services/comments/comments-service_test.ts
@@ -43,7 +43,10 @@
   });
 
   test('loads comments', async () => {
-    new CommentsService(getAppContext().restApiService);
+    new CommentsService(
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
     const diffCommentsSpy = stubRestApi('getDiffComments').returns(
       Promise.resolve({'foo.c': [createComment()]})
     );
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index c0e6028..663935f 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -43,6 +43,11 @@
     configState => configState.repoConfig
   );
 
+  public repoCommentLinks$ = select(
+    this.repoConfig$,
+    repoConfig => repoConfig?.commentlinks ?? {}
+  );
+
   public serverConfig$ = select(
     this.privateState$,
     configState => configState.serverConfig
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 679fefc..518716b 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -115,11 +115,6 @@
     eventName: string | Interaction,
     details?: EventDetails
   ): void;
-  /**
-   * A draft interaction was started. Update the time-between-draft-actions
-   * timer.
-   */
-  recordDraftInteraction(): void;
   reportErrorDialog(message: string): void;
   setRepoName(repoName: string): void;
   setChangeId(changeId: NumericChangeId): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 7d03de5..a01e9db 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -98,8 +98,6 @@
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
 const SLOW_RPC_THRESHOLD = 500;
 
 export function initErrorReporter(reportingService: ReportingService) {
@@ -282,10 +280,6 @@
 
   private reportChangeId: NumericChangeId | undefined;
 
-  private timers: {timeBetweenDraftActions: Timer | null} = {
-    timeBetweenDraftActions: null,
-  };
-
   private pending: PendingReportInfo[] = [];
 
   private slowRpcList: SlowRpcCall[] = [];
@@ -855,27 +849,6 @@
     this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
   }
 
-  /**
-   * A draft interaction was started. Update the time-between-draft-actions
-   * Timing.
-   */
-  recordDraftInteraction() {
-    // If there is no timer defined, then this is the first interaction.
-    // Set up the timer so that it's ready to record the intervening time when
-    // called again.
-    const timer = this.timers.timeBetweenDraftActions;
-    if (!timer) {
-      // Create a timer with a maximum length.
-      this.timers.timeBetweenDraftActions = this.getTimer(
-        DRAFT_ACTION_TIMER
-      ).withMaximum(DRAFT_ACTION_TIMER_MAX);
-      return;
-    }
-
-    // Mark the time and reinitialize the timer.
-    timer.end().reset();
-  }
-
   error(error: Error, errorSource?: string, details?: EventDetails) {
     const eventDetails = details ?? {};
     const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 485402b..2a5c532 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -59,7 +59,6 @@
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
-  recordDraftInteraction: () => {},
   reporter: () => {},
   reportErrorDialog: (message: string) => {
     log(`reportErrorDialog: ${message}`);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index f6e87f9..8068dc00 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -282,30 +282,6 @@
     assert.isTrue(service.reporter.calledOnce);
   });
 
-  test('recordDraftInteraction', () => {
-    const key = 'TimeBetweenDraftActions';
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timingStub = sinon.stub(service, '_reportTiming');
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.called);
-
-    nowStub.returns(200);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledOnce);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 100);
-
-    nowStub.returns(350);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledTwice);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 150);
-
-    nowStub.returns(370 + 2 * 60 * 1000);
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.calledThrice);
-  });
-
   test('timeEndWithAverage', () => {
     const nowStub = sinon.stub(window.performance, 'now').returns(0);
     nowStub.returns(1000);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index ec0cc57..40ef0ed 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -109,6 +109,7 @@
 } from '../../types/diff';
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
+import {DraftInfo} from '../../utils/comment-util';
 
 export type CancelConditionCallback = () => boolean;
 
@@ -450,21 +451,7 @@
 
   getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ):
-    | Promise<GetDiffCommentsOutput>
-    | Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   createGroup(config: GroupInput & {name: string}): Promise<Response>;
 
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 8f14077..e61ab15 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -119,7 +119,7 @@
     );
     this.keydownListener = (e: KeyboardEvent) => {
       if (!isComboKey(e.key)) return;
-      if (this.shouldSuppress(e)) return;
+      if (this.shortcutsDisabled || shouldSuppress(e)) return;
       this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
     };
     document.addEventListener('keydown', this.keydownListener);
@@ -159,7 +159,12 @@
   addShortcut(
     element: HTMLElement,
     shortcut: Binding,
-    listener: (e: KeyboardEvent) => void
+    listener: (e: KeyboardEvent) => void,
+    options: {
+      shouldSuppress: boolean;
+    } = {
+      shouldSuppress: true,
+    }
   ) {
     const wrappedListener = (e: KeyboardEvent) => {
       if (e.repeat && !shortcut.allowRepeat) return;
@@ -169,19 +174,21 @@
       } else {
         if (this.isInComboKeyMode()) return;
       }
-      if (this.shouldSuppress(e)) return;
+      if (options.shouldSuppress && shouldSuppress(e)) return;
+      // `shortcutsDisabled` refers to disabling global shortcuts like 'n'. If
+      // `shouldSuppress` is false (e.g.for Ctrl - ENTER), then don't disable
+      // the shortcut.
+      if (options.shouldSuppress && this.shortcutsDisabled) return;
       e.preventDefault();
       e.stopPropagation();
+      this.reportTriggered(e);
       listener(e);
     };
     element.addEventListener('keydown', wrappedListener);
     return () => element.removeEventListener('keydown', wrappedListener);
   }
 
-  shouldSuppress(e: KeyboardEvent) {
-    if (this.shortcutsDisabled) return true;
-    if (shouldSuppress(e)) return true;
-
+  private reportTriggered(e: KeyboardEvent) {
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
     if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -195,7 +202,6 @@
       from = e.currentTarget.tagName;
     }
     this.reporting?.reportInteraction('shortcut-triggered', {key, from});
-    return false;
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 274cb87..7dd3f75 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -21,27 +21,10 @@
   ShortcutsService,
 } from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {Key, Modifier} from '../../utils/dom-util';
 import {getAppContext} from '../app-context';
 
-async function keyEventOn(
-  el: HTMLElement,
-  callback: (e: KeyboardEvent) => void,
-  keyCode = 75,
-  key = 'k'
-): Promise<KeyboardEvent> {
-  let resolve: (e: KeyboardEvent) => void;
-  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
-  el.addEventListener('keydown', (e: KeyboardEvent) => {
-    callback(e);
-    resolve(e);
-  });
-  MockInteractions.keyDownOn(el, keyCode, null, key);
-  return await promise;
-}
-
 suite('shortcuts-service tests', () => {
   let service: ShortcutsService;
 
@@ -52,55 +35,6 @@
     );
   });
 
-  suite('shouldSuppress', () => {
-    test('do not suppress shortcut event from <div>', async () => {
-      await keyEventOn(document.createElement('div'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <input>', async () => {
-      await keyEventOn(document.createElement('input'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <textarea>', async () => {
-      await keyEventOn(document.createElement('textarea'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('do not suppress shortcut event from checkbox <input>', async () => {
-      const inputEl = document.createElement('input');
-      inputEl.setAttribute('type', 'checkbox');
-      await keyEventOn(inputEl, e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress "enter" shortcut event from <a>', async () => {
-      await keyEventOn(document.createElement('a'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-      await keyEventOn(
-        document.createElement('a'),
-        e => assert.isTrue(service.shouldSuppress(e)),
-        13,
-        'enter'
-      );
-    });
-  });
-
   test('getShortcut', () => {
     assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
     assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 0b6ef6b..9107b31d 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -14,14 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {from, of, BehaviorSubject, Observable, Subscription} from 'rxjs';
 import {switchMap} from 'rxjs/operators';
 import {
   DiffPreferencesInfo as DiffPreferencesInfoAPI,
   DiffViewMode,
 } from '../../api/diff';
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
+import {
+  AccountCapabilityInfo,
+  AccountDetailInfo,
+  PreferencesInfo,
+} from '../../types/common';
 import {
   createDefaultPreferences,
   createDefaultDiffPrefs,
@@ -38,6 +41,7 @@
   account?: AccountDetailInfo;
   preferences: PreferencesInfo;
   diffPreferences: DiffPreferencesInfo;
+  capabilities?: AccountCapabilityInfo;
 }
 
 export class UserModel implements Finalizable {
@@ -58,6 +62,14 @@
     account => !!account
   );
 
+  readonly capabilities$: Observable<AccountCapabilityInfo | undefined> =
+    select(this.userState$, userState => userState.capabilities);
+
+  readonly isAdmin$: Observable<boolean> = select(
+    this.capabilities$,
+    capabilities => capabilities?.administrateServer ?? false
+  );
+
   readonly preferences$: Observable<PreferencesInfo> = select(
     this.privateState$,
     userState => userState.preferences
@@ -106,6 +118,16 @@
         .subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
           this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
         }),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(undefined);
+            return from(this.restApiService.getAccountCapabilities());
+          })
+        )
+        .subscribe((capabilities?: AccountCapabilityInfo) => {
+          this.setCapabilities(capabilities);
+        }),
     ];
   }
 
@@ -154,6 +176,11 @@
     this.privateState$.next({...current, diffPreferences});
   }
 
+  setCapabilities(capabilities?: AccountCapabilityInfo) {
+    const current = this.privateState$.getValue();
+    this.privateState$.next({...current, capabilities});
+  }
+
   private setAccount(account?: AccountDetailInfo) {
     const current = this.privateState$.getValue();
     this.privateState$.next({...current, account});
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 160b543..8bf6b25 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -53,7 +53,8 @@
     },
     commentsService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.restApiService, 'restApiService');
-      return new CommentsService(ctx.restApiService!);
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new CommentsService(ctx.restApiService!, ctx.reportingService!);
     },
     checksModel: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.reportingService, 'reportingService');
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 87760de..57d2b0c 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {
   AccountDetailInfo,
   AccountId,
@@ -31,6 +30,7 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
   CommitId,
@@ -62,6 +62,9 @@
   RequirementType,
   Reviewers,
   RevisionInfo,
+  RobotCommentInfo,
+  RobotId,
+  RobotRunId,
   SchemesInfoMap,
   ServerInfo,
   SubmittedTogetherInfo,
@@ -96,10 +99,9 @@
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
 import {
+  CommentThread,
   createCommentThreads,
-  UIComment,
-  UIDraft,
-  UIHuman,
+  DraftInfo,
 } from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
@@ -504,7 +506,9 @@
   };
 }
 
-export function createComment(): UIHuman {
+export function createComment(
+  extra: Partial<CommentInfo | DraftInfo> = {}
+): CommentInfo {
   return {
     patch_set: 1 as PatchSetNum,
     id: '12345' as UrlEncodedCommentId,
@@ -514,15 +518,28 @@
     updated: '2018-02-13 22:48:48.018000000' as Timestamp,
     unresolved: false,
     path: 'abc.txt',
+    ...extra,
   };
 }
 
-export function createDraft(): UIDraft {
+export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    collapsed: false,
     __draft: true,
-    __editing: false,
+    ...extra,
+  };
+}
+
+export function createRobotComment(
+  extra: Partial<CommentInfo> = {}
+): RobotCommentInfo {
+  return {
+    ...createComment(),
+    robot_id: 'robot-id-123' as RobotId,
+    robot_run_id: 'robot-run-id-456' as RobotRunId,
+    properties: {},
+    fix_suggestions: [],
+    ...extra,
   };
 }
 
@@ -629,14 +646,27 @@
   return new ChangeComments(comments, {}, drafts, {}, {});
 }
 
-export function createCommentThread(comments: UIComment[]) {
+export function createThread(
+  ...comments: Partial<CommentInfo | DraftInfo>[]
+): CommentThread {
+  return {
+    comments: comments.map(c => createComment(c)),
+    rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
+    path: 'test-path-comment-thread',
+    commentSide: CommentSide.REVISION,
+    patchNum: 1 as PatchSetNum,
+    line: 314,
+  };
+}
+
+export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
   if (!comments.length) {
     throw new Error('comment is required to create a thread');
   }
-  comments = comments.map(comment => {
+  const filledComments = comments.map(comment => {
     return {...createComment(), ...comment};
   });
-  const threads = createCommentThreads(comments);
+  const threads = createCommentThreads(filledComments);
   return threads[0];
 }
 
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index f2da972..e9d170d 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -218,17 +218,20 @@
  *   await listenOnce(el, 'render');
  *   ...
  */
-export function listenOnce(el: EventTarget, eventType: string) {
-  return new Promise<void>(resolve => {
-    const listener = () => {
+export function listenOnce<T extends Event>(
+  el: EventTarget,
+  eventType: string
+) {
+  return new Promise<T>(resolve => {
+    const listener = (e: Event) => {
       removeEventListener();
-      resolve();
+      resolve(e as T);
     };
-    el.addEventListener(eventType, listener);
     let removeEventListener = () => {
       el.removeEventListener(eventType, listener);
       removeEventListener = () => {};
     };
+    el.addEventListener(eventType, listener);
     registerTestCleanup(removeEventListener);
   });
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index fc38756..f58abb3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -692,9 +692,10 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
 export interface CommentInfo {
-  // TODO(TS): Make this required.
-  patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
+  updated: Timestamp;
+  // TODO(TS): Make this required. Every comment must have patch_set set.
+  patch_set?: PatchSetNum;
   path?: string;
   side?: CommentSide;
   parent?: number;
@@ -702,7 +703,6 @@
   range?: CommentRange;
   in_reply_to?: UrlEncodedCommentId;
   message?: string;
-  updated: Timestamp;
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index f467cf6..4f24535 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {PatchSetNum} from './common';
-import {UIComment} from '../utils/comment-util';
+import {Comment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
@@ -168,7 +168,7 @@
 
 export interface OpenFixPreviewEventDetail {
   patchNum?: PatchSetNum;
-  comment?: UIComment;
+  comment?: Comment;
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
@@ -178,7 +178,7 @@
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
 export interface CreateFixCommentEventDetail {
   patchNum?: PatchSetNum;
-  comment?: UIComment;
+  comment?: Comment;
 }
 export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
 
@@ -249,3 +249,12 @@
   title: string;
 }
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+/**
+ * This event can be used for Polymer properties that have `notify: true` set.
+ * But it is also generally recommended when you want to notify your parent
+ * elements about a property update, also for Lit elements.
+ *
+ * The name of the event should be `prop-name-changed`.
+ */
+export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5b08fab..966a75c 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -30,84 +30,116 @@
   AccountInfo,
   AccountDetailInfo,
 } from '../types/common';
-import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
+import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
-import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
 import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {isMergeParent, getParentIndex} from './patch-set-util';
 import {DiffInfo} from '../types/diff';
+import {LineNumber} from '../api/diff';
 
 export interface DraftCommentProps {
-  __draft?: boolean;
-  __draftID?: string;
-  __date?: Date;
+  // This must be true for all drafts. Drafts received from the backend will be
+  // modified immediately with __draft:true before allowing them to get into
+  // the application state.
+  __draft: boolean;
 }
 
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
-  collapsed?: boolean;
+export interface UnsavedCommentProps {
+  // This must be true for all unsaved comment drafts. An unsaved draft is
+  // always just local to a comment component like <gr-comment> or
+  // <gr-comment-thread>. Unsaved drafts will never appear in the application
+  // state.
+  __unsaved: boolean;
 }
 
-export interface UIStateDraftProps {
-  __editing?: boolean;
-}
+export type DraftInfo = CommentInfo & DraftCommentProps;
 
-export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
+export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
 
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
+export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
 
 export type CommentMap = {[path: string]: boolean};
 
-export function isRobot<T extends CommentInfo>(
+export function isRobot<T extends CommentBasics>(
   x: T | DraftInfo | RobotCommentInfo | undefined
 ): x is RobotCommentInfo {
   return !!x && !!(x as RobotCommentInfo).robot_id;
 }
 
-export function isDraft<T extends CommentInfo>(
-  x: T | UIDraft | undefined
-): x is UIDraft {
-  return !!x && !!(x as UIDraft).__draft;
+export function isDraft<T extends CommentBasics>(
+  x: T | DraftInfo | undefined
+): x is DraftInfo {
+  return !!x && !!(x as DraftInfo).__draft;
+}
+
+export function isUnsaved<T extends CommentBasics>(
+  x: T | UnsavedInfo | undefined
+): x is UnsavedInfo {
+  return !!x && !!(x as UnsavedInfo).__unsaved;
+}
+
+export function isDraftOrUnsaved<T extends CommentBasics>(
+  x: T | DraftInfo | UnsavedInfo | undefined
+): x is UnsavedInfo | DraftInfo {
+  return isDraft(x) || isUnsaved(x);
 }
 
 interface SortableComment {
-  __draft?: boolean;
-  __date?: Date;
-  updated?: Timestamp;
-  id?: UrlEncodedCommentId;
+  updated: Timestamp;
+  id: UrlEncodedCommentId;
 }
 
 export function sortComments<T extends SortableComment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
-    const d1 = !!c1.__draft;
-    const d2 = !!c2.__draft;
+    const d1 = isDraft(c1);
+    const d2 = isDraft(c2);
     if (d1 !== d2) return d1 ? 1 : -1;
 
-    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
-    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+    const date1 = parseDate(c1.updated);
+    const date2 = parseDate(c2.updated);
     const dateDiff = date1!.valueOf() - date2!.valueOf();
     if (dateDiff !== 0) return dateDiff;
 
-    const id1 = c1.id ?? '';
-    const id2 = c2.id ?? '';
+    const id1 = c1.id;
+    const id2 = c2.id;
     return id1.localeCompare(id2);
   });
 }
 
-export function createCommentThreads(
-  comments: UIComment[],
-  patchRange?: PatchRange
-) {
+export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+  return {
+    path: thread.path,
+    patch_set: thread.patchNum,
+    side: thread.commentSide ?? CommentSide.REVISION,
+    line: typeof thread.line === 'number' ? thread.line : undefined,
+    range: thread.range,
+    parent: thread.mergeParentNum,
+    message: '',
+    unresolved: true,
+    __unsaved: true,
+  };
+}
+
+export function createUnsavedReply(
+  replyingTo: CommentInfo,
+  message: string,
+  unresolved: boolean
+): UnsavedInfo {
+  return {
+    path: replyingTo.path,
+    patch_set: replyingTo.patch_set,
+    side: replyingTo.side,
+    line: replyingTo.line,
+    range: replyingTo.range,
+    parent: replyingTo.parent,
+    in_reply_to: replyingTo.id,
+    message,
+    unresolved,
+    __unsaved: true,
+  };
+}
+
+export function createCommentThreads(comments: CommentInfo[]) {
   const sortedComments = sortComments(comments);
   const threads: CommentThread[] = [];
   const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -129,7 +161,6 @@
     const newThread: CommentThread = {
       comments: [comment],
       patchNum: comment.patch_set,
-      diffSide: Side.LEFT,
       commentSide: comment.side ?? CommentSide.REVISION,
       mergeParentNum: comment.parent,
       path: comment.path,
@@ -137,13 +168,6 @@
       range: comment.range,
       rootId: comment.id,
     };
-    if (patchRange) {
-      if (isInBaseOfPatchRange(comment, patchRange))
-        newThread.diffSide = Side.LEFT;
-      else if (isInRevisionOfPatchRange(comment, patchRange))
-        newThread.diffSide = Side.RIGHT;
-      else throw new Error('comment does not belong in given patchrange');
-    }
     if (!comment.line && !comment.range) {
       newThread.line = 'FILE';
     }
@@ -154,68 +178,98 @@
 }
 
 export interface CommentThread {
-  comments: UIComment[];
+  /**
+   * This can only contain at most one draft. And if so, then it is the last
+   * comment in this list. This must not contain unsaved drafts.
+   */
+  comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+  /**
+   * Identical to the id of the first comment. If this is undefined, then the
+   * thread only contains an unsaved draft.
+   */
+  rootId?: UrlEncodedCommentId;
   path: string;
   commentSide: CommentSide;
   /* mergeParentNum is the merge parent number only valid for merge commits
      when commentSide is PARENT.
      mergeParentNum is undefined for auto merge commits
+     Same as `parent` in CommentInfo.
   */
   mergeParentNum?: number;
   patchNum?: PatchSetNum;
+  /* Different from CommentInfo, which just keeps the line undefined for
+     FILE comments. */
   line?: LineNumber;
-  /* rootId is optional since we create a empty comment thread element for
-     drafts and then create the draft which becomes the root */
-  rootId?: UrlEncodedCommentId;
-  diffSide?: Side;
   range?: CommentRange;
   ported?: boolean; // is the comment ported over from a previous patchset
   rangeInfoLost?: boolean; // if BE was unable to determine a range for this
 }
 
-export function getLastComment(thread?: CommentThread): UIComment | undefined {
-  const len = thread?.comments.length;
-  return thread && len ? thread.comments[len - 1] : undefined;
+export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+  const len = thread.comments.length;
+  return thread.comments[len - 1];
 }
 
-export function getFirstComment(thread?: CommentThread): UIComment | undefined {
-  return thread?.comments?.[0];
+export function getLastPublishedComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+  const len = publishedComments.length;
+  return publishedComments[len - 1];
 }
 
-export function countComments(thread?: CommentThread) {
-  return thread?.comments?.length ?? 0;
+export function getFirstComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  return thread.comments[0];
 }
 
-export function isPatchsetLevel(thread?: CommentThread): boolean {
-  return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+export function countComments(thread: CommentThread) {
+  return thread.comments.length;
 }
 
-export function isUnresolved(thread?: CommentThread): boolean {
+export function isPatchsetLevel(thread: CommentThread): boolean {
+  return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function isUnresolved(thread: CommentThread): boolean {
   return !isResolved(thread);
 }
 
-export function isResolved(thread?: CommentThread): boolean {
-  return !getLastComment(thread)?.unresolved;
+export function isResolved(thread: CommentThread): boolean {
+  const lastUnresolved = getLastComment(thread)?.unresolved;
+  return !lastUnresolved ?? false;
 }
 
-export function isDraftThread(thread?: CommentThread): boolean {
+export function isDraftThread(thread: CommentThread): boolean {
   return isDraft(getLastComment(thread));
 }
 
-export function isRobotThread(thread?: CommentThread): boolean {
+export function isRobotThread(thread: CommentThread): boolean {
   return isRobot(getFirstComment(thread));
 }
 
-export function hasHumanReply(thread?: CommentThread): boolean {
+export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }
 
+export function lastUpdated(thread: CommentThread): Date | undefined {
+  // We don't want to re-sort comments when you save a draft reply, so
+  // we stick to the timestampe of the last *published* comment.
+  const lastUpdated =
+    getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
+  return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
+}
 /**
  * Whether the given comment should be included in the base side of the
  * given patch range.
  */
 export function isInBaseOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+    parent?: number;
+  },
   range: PatchRange
 ) {
   // If the base of the patch range is a parent of a merge, and the comment
@@ -249,7 +303,10 @@
  * given patch range.
  */
 export function isInRevisionOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+  },
   range: PatchRange
 ) {
   return (
@@ -271,7 +328,7 @@
 }
 
 export function getPatchRangeForCommentUrl(
-  comment: UIComment,
+  comment: Comment,
   latestPatchNum: RevisionPatchSetNum
 ) {
   if (!comment.patch_set) throw new Error('Missing comment.patch_set');
@@ -279,7 +336,7 @@
   // TODO(dhruvsri): Add handling for comment left on parents of merge commits
   if (comment.side === CommentSide.PARENT) {
     if (comment.patch_set === ParentPatchSetNum)
-      throw new Error('diffSide cannot be PARENT');
+      throw new Error('comment.patch_set cannot be PARENT');
     return {
       patchNum: comment.patch_set as RevisionPatchSetNum,
       basePatchNum: ParentPatchSetNum,
@@ -355,30 +412,46 @@
   return authors;
 }
 
-export function computeId(comment: UIComment) {
-  if (comment.id) return comment.id;
-  if (isDraft(comment)) return comment.__draftID;
-  throw new Error('Missing id in root comment.');
-}
-
 /**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
+ * Add path info to every comment as CommentInfo returned from server does not
+ * have that.
  */
 export function addPath<T>(comments: {[path: string]: T[]} = {}): {
   [path: string]: Array<T & {path: string}>;
 } {
   const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
   for (const filePath of Object.keys(comments)) {
-    const allCommentsForPath = comments[filePath] || [];
-    if (allCommentsForPath.length) {
-      updatedComments[filePath] = allCommentsForPath.map(comment => {
-        return {...comment, path: filePath};
-      });
-    }
+    updatedComments[filePath] = (comments[filePath] || []).map(comment => {
+      return {...comment, path: filePath};
+    });
   }
   return updatedComments;
 }
+
+/**
+ * Add __draft:true to all drafts returned from server so that they can be told
+ * apart from published comments easily.
+ */
+export function addDraftProp(
+  draftsByPath: {[path: string]: CommentInfo[]} = {}
+) {
+  const updated: {[path: string]: DraftInfo[]} = {};
+  for (const filePath of Object.keys(draftsByPath)) {
+    updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
+      return {...draft, __draft: true};
+    });
+  }
+  return updated;
+}
+
+export function reportingDetails(comment: CommentBasics) {
+  return {
+    id: comment?.id,
+    message_length: comment?.message?.trim().length,
+    in_reply_to: comment?.in_reply_to,
+    unresolved: comment?.unresolved,
+    path_length: comment?.path?.length,
+    line: comment?.range?.start_line ?? comment?.line,
+    unsaved: isUnsaved(comment),
+  };
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 3c8f26d..f5a2177 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -23,9 +23,8 @@
   sortComments,
 } from './comment-util';
 import {createComment, createCommentThread} from '../test/test-data-generators';
-import {CommentSide, Side} from '../constants/constants';
+import {CommentSide} from '../constants/constants';
 import {
-  BasePatchSetNum,
   ParentPatchSetNum,
   PatchSetNum,
   RevisionPatchSetNum,
@@ -37,7 +36,6 @@
   test('isUnresolved', () => {
     const thread = createCommentThread([createComment()]);
 
-    assert.isFalse(isUnresolved(undefined));
     assert.isFalse(isUnresolved(thread));
 
     assert.isTrue(
@@ -97,7 +95,6 @@
       {
         id: 'new_draft' as UrlEncodedCommentId,
         message: 'i do not like either of you',
-        diffSide: Side.LEFT,
         __draft: true,
         updated: '2015-12-20 15:01:20.396000000' as Timestamp,
       },
@@ -106,13 +103,11 @@
         message: 'i like you, jack',
         updated: '2015-12-23 15:00:20.396000000' as Timestamp,
         line: 1,
-        diffSide: Side.LEFT,
       },
       {
         id: 'jacks_reply' as UrlEncodedCommentId,
         message: 'i like you, too',
         updated: '2015-12-24 15:01:20.396000000' as Timestamp,
-        diffSide: Side.LEFT,
         line: 1,
         in_reply_to: 'sallys_confession',
       },
@@ -153,21 +148,16 @@
         },
       ];
 
-      const actualThreads = createCommentThreads(comments, {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 4 as RevisionPatchSetNum,
-      });
+      const actualThreads = createCommentThreads(comments);
 
       assert.equal(actualThreads.length, 2);
 
-      assert.equal(actualThreads[0].diffSide, Side.LEFT);
       assert.equal(actualThreads[0].comments.length, 2);
       assert.deepEqual(actualThreads[0].comments[0], comments[0]);
       assert.deepEqual(actualThreads[0].comments[1], comments[1]);
       assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
       assert.equal(actualThreads[0].line, 1);
 
-      assert.equal(actualThreads[1].diffSide, Side.LEFT);
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
       assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
@@ -194,7 +184,6 @@
 
       const expectedThreads = [
         {
-          diffSide: Side.LEFT,
           commentSide: CommentSide.REVISION,
           path: '/p',
           rootId: 'betsys_confession' as UrlEncodedCommentId,
@@ -226,13 +215,7 @@
         },
       ];
 
-      assert.deepEqual(
-        createCommentThreads(comments, {
-          basePatchNum: 5 as BasePatchSetNum,
-          patchNum: 10 as RevisionPatchSetNum,
-        }),
-        expectedThreads
-      );
+      assert.deepEqual(createCommentThreads(comments), expectedThreads);
     });
 
     test('does not thread unrelated comments at same location', () => {
@@ -241,14 +224,12 @@
           id: 'sallys_confession' as UrlEncodedCommentId,
           message: 'i like you, jack',
           updated: '2015-12-23 15:00:20.396000000' as Timestamp,
-          diffSide: Side.LEFT,
           path: '/p',
         },
         {
           id: 'jacks_reply' as UrlEncodedCommentId,
           message: 'i like you, too',
           updated: '2015-12-24 15:01:20.396000000' as Timestamp,
-          diffSide: Side.LEFT,
           path: '/p',
         },
       ];
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index c2991bf..b96ebe6 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -413,6 +413,12 @@
   return addShortcut(document.body, shortcut, listener, options);
 }
 
+/**
+ * Deprecated.
+ *
+ * For LitElement use the shortcut-controller.
+ * For PolymerElement use the keyboard-shortcut-mixin.
+ */
 export function addShortcut(
   element: HTMLElement,
   shortcut: Binding,
diff --git a/polygerrit-ui/app/utils/math-util.ts b/polygerrit-ui/app/utils/math-util.ts
new file mode 100644
index 0000000..adec7d3
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Returns a random integer between `from` and `to`, both included.
+ * So getRandomInt(0, 2) returns 0, 1, or 2 each with probability 1/3.
+ */
+export function getRandomInt(from: number, to: number) {
+  return Math.floor(Math.random() * (to + 1 - from) + from);
+}
diff --git a/polygerrit-ui/app/utils/math-util_test.ts b/polygerrit-ui/app/utils/math-util_test.ts
new file mode 100644
index 0000000..fca1d73
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma';
+import {getRandomInt} from './math-util';
+
+suite('math-util tests', () => {
+  test('getRandomInt', () => {
+    let r = 0;
+    const randomStub = sinon.stub(Math, 'random').callsFake(() => r);
+
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 0);
+    assert.equal(getRandomInt(0, 100), 0);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 10);
+    assert.equal(getRandomInt(10, 100), 10);
+
+    r = 0.999;
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 2);
+    assert.equal(getRandomInt(0, 100), 100);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 12);
+    assert.equal(getRandomInt(10, 100), 100);
+
+    r = 0.5;
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 1);
+    assert.equal(getRandomInt(0, 100), 50);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 11);
+    assert.equal(getRandomInt(10, 100), 55);
+
+    r = 0.0;
+    assert.equal(getRandomInt(0, 2), 0);
+    r = 0.33;
+    assert.equal(getRandomInt(0, 2), 0);
+    r = 0.34;
+    assert.equal(getRandomInt(0, 2), 1);
+    r = 0.66;
+    assert.equal(getRandomInt(0, 2), 1);
+    r = 0.67;
+    assert.equal(getRandomInt(0, 2), 2);
+    r = 0.99;
+    assert.equal(getRandomInt(0, 2), 2);
+
+    randomStub.restore();
+  });
+});