Merge "Make the js plugin hook and rest API typed"
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 8aa3173..410bf42 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1760,7 +1760,7 @@
   [
     {
       "identity": "username:john",
-      "email": "john.doe@example.com",
+      "email_address": "john.doe@example.com",
       "trusted": true
     }
   ]
@@ -2250,7 +2250,7 @@
 |============================
 |Field Name        ||Description
 |`identity`        ||The account external id.
-|`email`           |optional|The email address for the external id.
+|`email_address`   |optional|The email address for the external id.
 |`trusted`         |not set if `false`|
 Whether the external id is trusted.
 |`can_delete`      |not set if `false`|
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 36fc456..4a81f38 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -118,11 +118,7 @@
     "elements/diff/gr-diff-view/gr-diff-view_html.ts",
     "elements/diff/gr-diff/gr-diff_html.ts",
     "elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts",
-    "elements/documentation/gr-documentation-search/gr-documentation-search_html.ts",
-    "elements/edit/gr-edit-controls/gr-edit-controls_html.ts",
-    "elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts",
     "elements/gr-app-element_html.ts",
-    "elements/settings/gr-cla-view/gr-cla-view_html.ts",
     "elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts",
     "elements/settings/gr-email-editor/gr-email-editor_html.ts",
     "elements/settings/gr-identities/gr-identities_html.ts",
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index 3bda936..5922e5e 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -28,7 +28,7 @@
   basePatchNum?: number,
   patchNum?: number,
   change?: ChangeInfo
-) => Promise<Array<CoverageRange>>;
+) => Promise<Array<CoverageRange> | undefined>;
 
 export declare interface AnnotationPluginApi {
   /**
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 b78d687..39a80e6 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
@@ -104,7 +104,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   LabelNameToInfoMap,
   NumericChangeId,
   ParentPatchSetNum,
@@ -131,7 +130,6 @@
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
-  DraftInfo,
   isDraftThread,
   isRobot,
   isUnresolved,
@@ -164,7 +162,6 @@
   ShowAlertEventDetail,
   SwitchTabEvent,
   TabState,
-  ThreadListModifiedEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
@@ -185,6 +182,10 @@
 import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -419,7 +420,7 @@
 
   @property({
     type: String,
-    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+    computed: '_computeReplyButtonLabel(_diffDrafts, _canStartReview)',
   })
   _replyButtonLabel = 'Reply';
 
@@ -549,6 +550,8 @@
 
   restApiService = appContext.restApiService;
 
+  private readonly commentsService = appContext.commentsService;
+
   private replyDialogResizeObserver?: ResizeObserver;
 
   keyboardShortcuts() {
@@ -590,6 +593,14 @@
     routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
       this.isViewCurrent = view === GerritView.CHANGE;
     });
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this._diffDrafts = {...drafts};
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
   }
 
   constructor() {
@@ -609,15 +620,6 @@
       this._handleShowBackgroundContent()
     );
 
-    this.addEventListener('diff-comments-modified', () =>
-      this._handleReloadCommentThreads()
-    );
-
-    this.addEventListener(
-      'thread-list-modified',
-      (e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
-    );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
   }
 
@@ -665,11 +667,6 @@
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
-    this.addEventListener('comment-refresh', () => this._reloadDrafts());
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
-    );
     this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
       this._handleCommitMessageSave(e)
@@ -1033,30 +1030,6 @@
     );
   }
 
-  _handleReloadCommentThreads() {
-    // Get any new drafts that have been saved in the diff view and show
-    // in the comment thread view.
-    this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments?.getAllThreadsForChange();
-      flush();
-    });
-  }
-
-  _handleReloadDiffComments(
-    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
-  ) {
-    // Keeps the file list counts updated.
-    this._reloadDrafts().then(() => {
-      // Get any new drafts that have been saved in the thread view and show
-      // in the diff view.
-      this.$.fileList.reloadCommentsForThreadWithRootId(
-        e.detail.rootId,
-        e.detail.path
-      );
-      flush();
-    });
-  }
-
   _computeTotalCommentCounts(
     unresolvedCount: number,
     changeComments: ChangeComments
@@ -1075,79 +1048,6 @@
     );
   }
 
-  _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) return;
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    if (!diffDrafts[draft.path]) {
-      diffDrafts[draft.path] = [draft];
-      this._diffDrafts = diffDrafts;
-      return;
-    }
-    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
-      if (diffDrafts[draft.path][i].id === draft.id) {
-        diffDrafts[draft.path][i] = draft;
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-    }
-    diffDrafts[draft.path].push(draft);
-    diffDrafts[draft.path].sort(
-      (c1, c2) =>
-        // No line number means that it’s a file comment. Sort it above the
-        // others.
-        (c1.line || -1) - (c2.line || -1)
-    );
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) {
-      return;
-    }
-
-    if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
-      return;
-    }
-    let index = -1;
-    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-      if (this._diffDrafts[draft.path][i].id === draft.id) {
-        index = i;
-        break;
-      }
-    }
-    if (index === -1) {
-      // It may be a draft that hasn’t been added to _diffDrafts since it was
-      // never saved.
-      return;
-    }
-
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    diffDrafts[draft.path].splice(index, 1);
-    if (diffDrafts[draft.path].length === 0) {
-      delete diffDrafts[draft.path];
-    }
-    this._diffDrafts = diffDrafts;
-  }
-
   _handleReplyTap(e: MouseEvent) {
     e.preventDefault();
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
@@ -1576,17 +1476,13 @@
   }
 
   _computeReplyButtonLabel(
-    changeRecord?: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > | null,
+    drafts?: {[path: string]: UIDraft[]},
     canStartReview?: boolean
   ) {
-    if (changeRecord === undefined || canStartReview === undefined) {
+    if (drafts === undefined || canStartReview === undefined) {
       return 'Reply';
     }
 
-    const drafts = (changeRecord && changeRecord.base) || {};
     const draftCount = Object.keys(drafts).reduce(
       (count, file) => count + drafts[file].length,
       0
@@ -1986,9 +1882,9 @@
         // Slice returns a number as a string, convert to an int.
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
+        this.changeService.updateChange(change);
         this._change = change;
         this.computeRevertSubmitted(change);
-        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
@@ -2086,10 +1982,6 @@
       });
   }
 
-  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
-    return this._reloadDrafts().then(() => e.detail.resolve());
-  }
-
   /**
    * Fetches a new changeComment object, and data for all types of comments
    * (comments, robot comments, draft comments) is requested.
@@ -2099,38 +1991,18 @@
     // a new change being loaded and then paired with outdated comments.
     this._changeComments = undefined;
     this._commentThreads = undefined;
-    this._diffDrafts = undefined;
     this._draftCommentThreads = undefined;
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
 
-    return this.$.commentAPI
-      .loadAll(this._changeNum, this._patchRange?.patchNum)
-      .then(comments => {
-        this._recomputeComments(comments);
-      });
+    this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum);
   }
 
-  /**
-   * Fetches a new changeComment object, but only updated data for drafts is
-   * requested.
-   *
-   * TODO(taoalpha): clean up this and _reloadComments, as single comment
-   * can be a thread so it does not make sense to only update drafts
-   * without updating threads
-   */
-  _reloadDrafts() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    return this.$.commentAPI
-      .reloadDrafts(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
-  }
-
-  _recomputeComments(comments: ChangeComments) {
+  @observe('_changeComments')
+  changeCommentsChanged(comments?: ChangeComments) {
+    if (!comments) return;
     this._changeComments = comments;
-    this._diffDrafts = {...this._changeComments.drafts};
     this._commentThreads = this._changeComments.getAllThreadsForChange();
     this._draftCommentThreads = this._commentThreads
       .filter(isDraftThread)
@@ -2201,10 +2073,7 @@
     });
     allDataPromises.push(projectConfigLoaded);
 
-    // Resolves when change comments have loaded (comments, drafts and robot
-    // comments).
-    const commentsLoaded = this._reloadComments();
-    allDataPromises.push(commentsLoaded);
+    this._reloadComments();
 
     let coreDataPromise;
 
@@ -2289,6 +2158,7 @@
     }
 
     Promise.all(allDataPromises).then(() => {
+      // Loading of commments data is no longer part of this reporting
       this.reporting.timeEnd(Timing.CHANGE_DATA);
       if (isLocationChange) {
         this.reporting.changeFullyLoaded();
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 8823377..5b940a8 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
@@ -554,7 +554,6 @@
           file-list-increment="{{_numFilesShown}}"
           on-files-shown-changed="_setShownFiles"
           on-file-action-tap="_handleFileActionTap"
-          on-reload-drafts="_reloadDraftsWithCallback"
           observer-target="[[_computeObserverTarget()]]"
         >
         </gr-file-list>
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 7d47abb..e882920 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
@@ -26,7 +26,6 @@
   HttpMethod,
   MessageTag,
   PrimaryTab,
-  SecondaryTab,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -71,7 +70,6 @@
   CommitInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   GitRef,
   NumericChangeId,
   ParentPatchSetNum,
@@ -94,13 +92,7 @@
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
-import {
-  CommentThread,
-  DraftInfo,
-  UIDraft,
-  UIRobot,
-} from '../../../utils/comment-util';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, UIRobot} 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';
@@ -865,179 +857,6 @@
     });
   });
 
-  suite('reloading drafts', () => {
-    let reloadStub: SinonStubbedMember<
-      typeof element.$.commentAPI.reloadDrafts
-    >;
-    const drafts: {[path: string]: UIDraft[]} = {
-      'testfile.txt': [
-        {
-          patch_set: 5 as PatchSetNum,
-          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
-          line: 1,
-          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-    };
-    setup(() => {
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts,
-          getAllThreadsForChange: () => [] as CommentThread[],
-          computeDraftCount: () => 1,
-        } as ChangeComments)
-      );
-      element._changeNum = 1 as NumericChangeId;
-    });
-
-    test('drafts are reloaded when reload-drafts fired', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.isTrue(reloadStub.called);
-              assert.deepEqual(element._diffDrafts, drafts);
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-
-    test('drafts are reloaded when comment-refresh fired', () => {
-      element.dispatchEvent(
-        new CustomEvent('comment-refresh', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadStub.called);
-    });
-  });
-
-  suite('_recomputeComments', () => {
-    setup(() => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = createChangeViewChange();
-      flush();
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts: {},
-          getAllThreadsForChange: () => THREADS,
-          computeDraftCount: () => 0,
-        } as ChangeComments)
-      );
-      element._change = createChangeViewChange();
-      element._changeNum = element._change._number;
-    });
-
-    test('draft threads should be a new copy with correct states', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.equal(element._draftCommentThreads!.length, 2);
-              assert.equal(
-                element._draftCommentThreads![0].rootId,
-                THREADS[0].rootId
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments,
-                THREADS[0].comments
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments[0],
-                THREADS[0].comments[0]
-              );
-              assert.isTrue(
-                element
-                  ._draftCommentThreads![0].comments.slice(0, 2)
-                  .every(c => c.collapsed === true)
-              );
-
-              assert.isTrue(
-                element._draftCommentThreads![0].comments[2].collapsed === false
-              );
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  });
-
-  test('diff comments modified', () => {
-    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
-    return element._reloadComments().then(() => {
-      element.dispatchEvent(
-        new CustomEvent('diff-comments-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadThreadsSpy.called);
-    });
-  });
-
-  test('thread list modified', () => {
-    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
-    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
-    flush();
-
-    return element._reloadComments().then(() => {
-      element.threadList!.dispatchEvent(
-        new CustomEvent('thread-list-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadDiffSpy.called);
-
-      let draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(1);
-      assert.equal(
-        element._computeTotalCommentCounts(5, element._changeComments!),
-        '5 unresolved, 1 draft'
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        '1 draft'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(0);
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        ''
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(2);
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved, 2 drafts'
-      );
-      draftStub.restore();
-    });
-  });
-
   suite('thread list and change log tabs', () => {
     setup(() => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
@@ -1458,66 +1277,18 @@
   test('reply button has updated count when there are drafts', () => {
     const getLabel = element._computeReplyButtonLabel;
 
-    assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Start Review');
+    assert.equal(getLabel(undefined, false), 'Reply');
+    assert.equal(getLabel(undefined, true), 'Reply');
 
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > = {base: undefined, path: '', value: undefined};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
+    let drafts = {};
+    assert.equal(getLabel(drafts, false), 'Reply');
 
-    changeRecord.base = {};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {
+    drafts = {
       'file1.txt': [{}],
       'file2.txt': [{}, {}],
     };
-    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
-  });
-
-  test('comment events properly update diff drafts', () => {
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
-    const draft: DraftInfo = {
-      __draft: true,
-      id: 'id1' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hello',
-    };
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    draft.patch_set = undefined;
-    draft.message = 'hello, there';
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    const draft2: DraftInfo = {
-      __draft: true,
-      id: 'id2' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hola',
-    };
-    element._handleCommentSave(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    draft2.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-    draft.patch_set = undefined;
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft}})
-    );
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    assert.deepEqual(element._diffDrafts, {});
+    assert.equal(getLabel(drafts, false), 'Reply (3)');
+    assert.equal(getLabel(drafts, true), 'Start Review (3)');
   });
 
   test('change num change', () => {
@@ -2588,7 +2359,6 @@
       };
       sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
       sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
-      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
       sinon.stub(element, '_getMergeability').returns(Promise.resolve());
       sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
       sinon
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
index 6def4a5..bed9240 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -57,12 +57,7 @@
         class="rebaseOption"
         hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
       >
-        <input
-          id="rebaseOnParentInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnParent"
-        />
+        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
         <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
           Rebase on parent change
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 1185342..106fe27 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -17,6 +17,7 @@
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-comment-api/gr-comment-api';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
@@ -67,7 +68,6 @@
   FileNameToFileInfoMap,
   NumericChangeId,
   PatchRange,
-  UrlEncodedCommentId,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -81,8 +81,13 @@
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
 import {preferences$} from '../../../services/user/user-model';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
 import {Subject} from 'rxjs';
 import {takeUntil} from 'rxjs/operators';
+import {UIDraft} from '../../../utils/comment-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -176,12 +181,6 @@
     return htmlTemplate;
   }
 
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
   @property({type: Object})
   patchRange?: PatchRange;
 
@@ -316,6 +315,9 @@
   @property({type: Array})
   _dynamicPrependedContentEndpoints?: string[];
 
+  @property({type: Object})
+  diffDrafts?: {[path: string]: UIDraft[]} = {};
+
   private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
@@ -370,6 +372,14 @@
   /** @override */
   connectedCallback() {
     super.connectedCallback();
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this.diffDrafts = drafts;
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -1470,66 +1480,50 @@
       }
     }
 
-    return new Promise(resolve => {
-      this.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {resolve},
-          composed: true,
-          bubbles: true,
-        })
+    asyncForeach(files, (file, cancel) => {
+      const path = file.path;
+      this._cancelForEachDiff = cancel;
+
+      iter++;
+      console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        this.reporting.error(
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return Promise.resolve();
+      }
+      if (!this.diffPrefs) {
+        throw new Error('diffPrefs must be set');
+      }
+
+      const promises: Array<Promise<unknown>> = [diffElem.reload()];
+      if (this._loggedIn && !this.diffPrefs.manual_review) {
+        promises.push(this._reviewFile(path, true));
+      }
+      return Promise.all(promises);
+    }).then(() => {
+      this._cancelForEachDiff = undefined;
+      console.info('Finished expanding', initialCount, 'diff(s)');
+      this.reporting.timeEndWithAverage(
+        Timing.FILE_EXPAND_ALL,
+        Timing.FILE_EXPAND_ALL_AVG,
+        initialCount
       );
-    }).then(() =>
-      asyncForeach(files, (file, cancel) => {
-        const path = file.path;
-        this._cancelForEachDiff = cancel;
+      /* Block diff cursor from auto scrolling after files are done rendering.
+      * This prevents the bug where the screen jumps to the first diff chunk
+      * after files are done being rendered after the user has already begun
+      * scrolling.
+      * This also however results in the fact that the cursor does not auto
+      * focus on the first diff chunk on a small screen. This is however, a use
+      * case we are willing to not support for now.
 
-        iter++;
-        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
-        const diffElem = this._findDiffByPath(path, diffElements);
-        if (!diffElem) {
-          this.reporting.error(
-            new Error(`Did not find <gr-diff-host> element for ${path}`)
-          );
-          return Promise.resolve();
-        }
-        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
-          throw new Error(
-            'changeComments, patchRange and diffPrefs must be set'
-          );
-        }
-
-        diffElem.threads = this.changeComments.getThreadsBySideForFile(
-          file,
-          this.patchRange
-        );
-        const promises: Array<Promise<unknown>> = [diffElem.reload()];
-        if (this._loggedIn && !this.diffPrefs.manual_review) {
-          promises.push(this._reviewFile(path, true));
-        }
-        return Promise.all(promises);
-      }).then(() => {
-        this._cancelForEachDiff = undefined;
-        console.info('Finished expanding', initialCount, 'diff(s)');
-        this.reporting.timeEndWithAverage(
-          Timing.FILE_EXPAND_ALL,
-          Timing.FILE_EXPAND_ALL_AVG,
-          initialCount
-        );
-        /* Block diff cursor from auto scrolling after files are done rendering.
-       * This prevents the bug where the screen jumps to the first diff chunk
-       * after files are done being rendered after the user has already begun
-       * scrolling.
-       * This also however results in the fact that the cursor does not auto
-       * focus on the first diff chunk on a small screen. This is however, a use
-       * case we are willing to not support for now.
-
-       * Using handleDiffUpdate resulted in diffCursor.row being set which
-       * prevented the issue of scrolling to top when we expand the second
-       * file individually.
-       */
-        this.diffCursor.reInitAndUpdateStops();
-      })
-    );
+      * Using handleDiffUpdate resulted in diffCursor.row being set which
+      * prevented the issue of scrolling to top when we expand the second
+      * file individually.
+      */
+      this.diffCursor.reInitAndUpdateStops();
+    });
   }
 
   /** Cancel the rendering work of every diff in the list */
@@ -1552,47 +1546,6 @@
     return undefined;
   }
 
-  /**
-   * Reset the comments of a modified thread
-   */
-  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
-    // Don't bother continuing if we already know that the path that contains
-    // the updated comment thread is not expanded.
-    if (!this._expandedFiles.some(f => f.path === path)) {
-      return;
-    }
-    const diff = this.diffs.find(d => d.path === path);
-
-    if (!diff) {
-      throw new Error("Can't find diff by path");
-    }
-
-    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-    if (!threadEl) {
-      return;
-    }
-
-    if (!this.changeComments) {
-      throw new Error('changeComments must be set');
-    }
-
-    const newComments = this.changeComments.getCommentsForThread(rootId);
-
-    // If newComments is null, it means that a single draft was
-    // removed from a thread in the thread view, and the thread should
-    // no longer exist. Remove the existing thread element in the diff
-    // view.
-    if (!newComments) {
-      threadEl.fireRemoveSelf();
-      return;
-    }
-
-    threadEl.comments = newComments.map(c => {
-      return {...c};
-    });
-    flush();
-  }
-
   _handleEscKey(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
       return;
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 ee7d04e..c32e3e5 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
@@ -49,8 +49,7 @@
 const commentApiMock = createCommentApiMockWithTemplateElement(
     'gr-file-list-comment-api-mock', html`
     <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"
-        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+        change-comments="[[_changeComments]]"></gr-file-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
 `);
 
@@ -67,7 +66,6 @@
   let commentApiWrapper;
 
   let saveStub;
-  let loadCommentSpy;
 
   suiteSetup(() => {
     const kb = TestKeyboardShortcutBinder.push();
@@ -109,14 +107,7 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.diffPrefs = {};
       element.numFilesShown = 200;
@@ -126,6 +117,7 @@
       };
       saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
           () => Promise.resolve());
+      done();
     });
 
     test('correct number of files are shown', () => {
@@ -1008,7 +1000,7 @@
           FilesExpandedState.ALL);
     });
 
-    test('_renderInOrder', done => {
+    test('_renderInOrder', async () => {
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
       const diffs = [{
@@ -1038,15 +1030,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentSpy.called);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.isFalse(reviewStub.called);
     });
 
-    test('_renderInOrder logged in', done => {
+    test('_renderInOrder logged in', async () => {
       element._loggedIn = true;
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
@@ -1080,14 +1069,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.equal(reviewStub.callCount, 3);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.equal(reviewStub.callCount, 3);
     });
 
-    test('_renderInOrder respects diffPrefs.manual_review', () => {
+    test('_renderInOrder respects diffPrefs.manual_review', async () => {
       element._loggedIn = true;
       element.diffPrefs = {manual_review: true};
       const reviewStub = sinon.stub(element, '_reviewFile');
@@ -1098,14 +1085,14 @@
         reload() { return Promise.resolve(); },
       }];
 
-      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-        assert.isFalse(reviewStub.called);
-        delete element.diffPrefs.manual_review;
-        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-          assert.isTrue(reviewStub.called);
-          assert.isTrue(reviewStub.calledWithExactly('p', true));
-        });
-      });
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isFalse(reviewStub.called);
+      delete element.diffPrefs.manual_review;
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isTrue(reviewStub.called);
+      assert.isTrue(reviewStub.calledWithExactly('p', true));
     });
 
     test('_loadingChanged fired from reload in debouncer', async () => {
@@ -1504,14 +1491,12 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getCommentsForPath')
-            .withArgs('/COMMIT_MSG', {
-              basePatchNum: 'PARENT',
-              patchNum: 2,
-            })
-            .returns(diff.comments);
-      });
+      sinon.stub(diff.changeComments, 'getCommentsForPath')
+          .withArgs('/COMMIT_MSG', {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          })
+          .returns(diff.comments);
       await listenOnce(diff, 'render');
     }
 
@@ -1543,17 +1528,10 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.diffPrefs = {};
       element.change = {_number: 42, project: 'testRepo'};
       sinon.stub(element, '_reviewFile');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.numFilesShown = 75;
       element.selectedIndex = 0;
@@ -1581,6 +1559,7 @@
       };
       sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
       flush();
+      done();
     });
 
     test('cursor with individually opened files', async () => {
@@ -1805,110 +1784,6 @@
               .map(row => row.querySelector('gr-edit-file-controls'));
       assert.isTrue(editControls[0].classList.contains('invisible'));
     });
-
-    test('reloadCommentsForThreadWithRootId', async () => {
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = await renderAndGetNewDiffs(0);
-      flush();
-
-      // Two comment threads should be generated by renderAndGetNewDiffs
-      const threadEls = diffs[0].getThreadEls();
-      assert.equal(threadEls.length, 2);
-      const threadElsByRootId = new Map(
-          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-      assert.equal(thread1.comments.length, 1);
-      assert.equal(thread1.comments[0].message, 'a comment');
-      assert.equal(thread1.comments[0].line, 10);
-
-      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-      assert.equal(thread2.comments.length, 2);
-      assert.isTrue(thread2.comments[0].unresolved);
-      assert.equal(thread2.comments[0].message, 'another comment');
-      assert.equal(thread2.comments[0].line, 20);
-
-      const commentStub =
-          sinon.stub(element.changeComments, 'getCommentsForThread');
-      const commentStubRes1 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'another comment',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 10,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-14 22:07:43.000000000',
-          message: 'response',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ef',
-          line: 20,
-          in_reply_to: '503008e2_0ab203ee',
-          updated: '2018-02-15 22:07:43.000000000',
-          message: 'a third comment in the thread',
-          unresolved: true,
-        },
-      ];
-      commentStub.withArgs('503008e2_0ab203ee').returns(
-          commentStubRes1);
-      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-          commentStubRes2);
-
-      // Reload comments from the first comment thread, which should have a
-      // an updated message and a toggled resolve state.
-      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-          '/COMMIT_MSG');
-      assert.equal(thread1.comments.length, 1);
-      assert.isFalse(thread1.comments[0].unresolved);
-      assert.equal(thread1.comments[0].message, 'edited text');
-
-      // Reload comments from the second comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.equal(thread2.comments.length, 3);
-
-      const commentStubCount = commentStub.callCount;
-      const getThreadsSpy = sinon.spy(diffs[0], 'getThreadEls');
-
-      // Should not be getting threads when the file is not expanded.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          'other/file');
-      assert.isFalse(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-
-      // Should be query selecting diffs when the file is expanded.
-      // Should not be fetching change comments when the rootId is not found
-      // to match.
-      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.isTrue(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-    });
   });
 });
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index fd45eec..0939daa 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -23,6 +23,7 @@
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -143,11 +144,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
+      element.changeComments = new ChangeComments(comments);
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('expand/collapse all', () => {
@@ -454,12 +453,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
-      sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.changeComments = new ChangeComments();
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('hide autogenerated button is not hidden', () => {
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 36cb053..5e6b076 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
@@ -47,7 +47,6 @@
   UIComment,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {fireThreadListModifiedEvent} from '../../../utils/event-util';
 import {assertIsDefined, assertNever} from '../../../utils/common-util';
 import {CommentTabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
@@ -571,10 +570,6 @@
     this.removeThread(e.detail.rootId);
   }
 
-  _handleCommentsChanged(e: CustomEvent) {
-    fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
-  }
-
   _isOnParent(side?: CommentSide) {
     // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
     // classified as parent??
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
index b5d2b4d..73f3dd3 100644
--- 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
@@ -168,7 +168,6 @@
         path="[[thread.path]]"
         root-id="{{thread.rootId}}"
         should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
-        on-thread-changed="_handleCommentsChanged"
         on-thread-discard="_handleThreadDiscard"
       ></gr-comment-thread>
     </template>
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
index 56ed8ce..61f737a 100644
--- 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
@@ -651,23 +651,6 @@
     assert.equal(getVisibleThreads().length, 2);
   });
 
-  test('modification events are consumed and displatched', () => {
-    sinon.spy(element, '_handleCommentsChanged');
-    const dispatchSpy = sinon.stub();
-    element.addEventListener('thread-list-modified', dispatchSpy);
-    threadElements[0].dispatchEvent(
-        new CustomEvent('thread-changed', {
-          detail: {
-            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleCommentsChanged.called);
-    assert.isTrue(dispatchSpy.called);
-    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-        'ecf0b9fa_fe1a5f62');
-    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
-  });
-
   suite('hideDropdown', () => {
     setup(done => {
       element.hideDropdown = true;
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 bf4da14..315fbfb 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
@@ -16,13 +16,11 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
   PatchRange,
   PatchSetNum,
-  PathToRobotCommentsInfoMap,
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
@@ -44,6 +42,7 @@
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
   isPatchsetLevel,
+  addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
@@ -56,7 +55,7 @@
 };
 
 export class ChangeComments {
-  private readonly _comments: {[path: string]: CommentInfo[]};
+  private readonly _comments: PathToCommentsInfoMap;
 
   private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
 
@@ -71,41 +70,19 @@
    * elements of that which uses the gr-comment-api.
    */
   constructor(
-    comments: {[path: string]: CommentInfo[]} | undefined,
+    comments: PathToCommentsInfoMap | undefined,
     robotComments: {[path: string]: RobotCommentInfo[]} | undefined,
     drafts: {[path: string]: DraftInfo[]} | undefined,
     portedComments: PathToCommentsInfoMap | undefined,
     portedDrafts: PathToCommentsInfoMap | undefined
   ) {
-    this._comments = this._addPath(comments);
-    this._robotComments = this._addPath(robotComments);
-    this._drafts = this._addPath(drafts);
+    this._comments = addPath(comments);
+    this._robotComments = addPath(robotComments);
+    this._drafts = addPath(drafts);
     this._portedComments = portedComments || {};
     this._portedDrafts = portedDrafts || {};
   }
 
-  /**
-   * 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
-   */
-  _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};
-        });
-      }
-    }
-    return updatedComments;
-  }
-
   get drafts() {
     return this._drafts;
   }
@@ -627,12 +604,6 @@
   }
 }
 
-// TODO(TS): move findCommentById out of class
-export const _testOnly_findCommentById =
-  ChangeComments.prototype.findCommentById;
-
-export const _testOnly_getCommentsForPath =
-  ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
 export class GrCommentApi extends PolymerElement {
   static get template() {
@@ -644,56 +615,11 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /**
-   * Load all comments (with drafts and robot comments) for the given change
-   * number. The returned promise resolves when the comments have loaded, but
-   * does not yield the comment data.
-   */
-  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
-    const revision = patchNum || CURRENT;
-    const commentsPromise = [
-      this.restApiService.getDiffComments(changeNum),
-      this.restApiService.getDiffRobotComments(changeNum),
-      this.restApiService.getDiffDrafts(changeNum),
-      this.restApiService.getPortedComments(changeNum, revision),
-      this.restApiService.getPortedDrafts(changeNum, revision),
-    ];
-
-    return Promise.all(commentsPromise).then(
-      ([comments, robotComments, drafts, portedComments, portedDrafts]) => {
-        this._changeComments = new ChangeComments(
-          comments,
-          // TS 4.0.5 fails without 'as'
-          robotComments as PathToRobotCommentsInfoMap | undefined,
-          drafts,
-          portedComments,
-          portedDrafts
-        );
-        return this._changeComments;
-      }
-    );
-  }
-
-  /**
-   * Re-initialize _changeComments with a new ChangeComments object, that
-   * uses the previous values for comments and robot comments, but fetches
-   * updated draft comments.
-   */
-  reloadDrafts(changeNum: NumericChangeId) {
-    if (!this._changeComments) {
-      return this.loadAll(changeNum);
-    }
-    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
-        drafts
-      );
-      return this._changeComments;
-    });
-  }
+  private readonly commentsService = appContext.commentsService;
 
   reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
     if (!this._changeComments) {
-      this.loadAll(changeNum);
+      this.commentsService.loadAll(changeNum);
       return Promise.resolve();
     }
     return Promise.all([
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 fbbe1fb..7e01371 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
@@ -34,107 +34,11 @@
     element = basicFixture.instantiate();
   });
 
-  test('loads logged-out', () => {
-    const changeNum = 1234;
-
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-        Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-        Promise.resolve({}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-      assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-      assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.deepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234;
-
-    const getCommentsStub = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        })
-    );
-    const getRobotCommentsStub = stubRestApi('getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const getDraftsStub = stubRestApi('getDiffDrafts')
-        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(getCommentsStub.calledWithExactly(changeNum));
-      assert.isTrue(getRobotCommentsStub.calledWithExactly(changeNum));
-      assert.isTrue(getDraftsStub.calledWithExactly(changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.notDeepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  suite('reloadDrafts', () => {
-    let commentStub;
-    let robotCommentStub;
-    let draftStub;
-    setup(() => {
-      commentStub = stubRestApi('getDiffComments')
-          .returns(Promise.resolve({}));
-      robotCommentStub = stubRestApi(
-          'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = stubRestApi('getDiffDrafts')
-          .returns(Promise.resolve({}));
-    });
-
-    test('without loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      sinon.spy(element, 'loadAll');
-      element.reloadDrafts().then(() => {
-        assert.isTrue(element.loadAll.called);
-        assert.isOk(element._changeComments);
-        assert.equal(commentStub.callCount, 1);
-        assert.equal(robotCommentStub.callCount, 1);
-        assert.equal(draftStub.callCount, 1);
-        done();
-      });
-    });
-
-    test('with loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      element.loadAll()
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 1);
-            return element.reloadDrafts();
-          })
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 2);
-            done();
-          });
-    });
-  });
-
   suite('_changeComment methods', () => {
-    setup(done => {
-      const changeNum = 1234;
+    setup(() => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      element.loadAll(changeNum).then(() => {
-        done();
-      });
     });
 
     suite('ported comments', () => {
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 72c7a62..d26e1af 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
@@ -87,6 +87,10 @@
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {Subject} from 'rxjs';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -145,12 +149,6 @@
    * @event show-auth-required
    */
 
-  /**
-   * Fired when a comment is saved or discarded
-   *
-   * @event diff-comments-modified
-   */
-
   @property({type: Number})
   changeNum?: NumericChangeId;
 
@@ -238,6 +236,9 @@
   diff?: DiffInfo;
 
   @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
   _fetchDiffPromise: Promise<DiffInfo> | null = null;
 
   @property({type: Object})
@@ -272,6 +273,8 @@
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
+  disconnected$ = new Subject();
+
   constructor() {
     super();
     this.addEventListener(
@@ -284,12 +287,6 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', () =>
-      this._handleCommentSaveOrDiscard()
-    );
-    this.addEventListener('comment-save', () =>
-      this._handleCommentSaveOrDiscard()
-    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -314,10 +311,16 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
   }
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     this.clear();
     super.disconnectedCallback();
   }
@@ -502,6 +505,16 @@
     );
   }
 
+  @observe('changeComments', 'patchRange', 'file')
+  computeFileThreads(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    file?: PatchSetFile
+  ) {
+    if (!changeComments || !patchRange || !file) return;
+    this.threads = changeComments.getThreadsBySideForFile(file, patchRange);
+  }
+
   _getFilesWeblinks(diff: DiffInfo) {
     if (!this.projectName || !this.commitRange || !this.path) return {};
     return {
@@ -1010,10 +1023,6 @@
       : null;
   }
 
-  _handleCommentSaveOrDiscard() {
-    fireEvent(this, 'diff-comments-modified');
-  }
-
   _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
     this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
   }
@@ -1168,7 +1177,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-discard': CustomEvent;
     'comment-update': CustomEvent;
     'comment-save': CustomEvent;
     'root-id-changed': CustomEvent;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 54985bb..fe8a1f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -107,6 +107,9 @@
 import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {throttleWrap} from '../../../utils/async-util';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -332,12 +335,16 @@
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly commentsService = appContext.commentsService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
 
   private cursor = new GrDiffCursor();
 
+  disconnected$ = new Subject();
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -347,7 +354,11 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
@@ -358,6 +369,7 @@
 
   /** @override */
   disconnectedCallback() {
+    this.disconnected$.next();
     this.cursor.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
@@ -365,6 +377,26 @@
     super.disconnectedCallback();
   }
 
+  @observe('_changeComments', '_path', '_patchRange')
+  computeThreads(
+    changeComments?: ChangeComments,
+    path?: string,
+    patchRange?: PatchRange
+  ) {
+    if (
+      changeComments === undefined ||
+      path === undefined ||
+      patchRange === undefined
+    ) {
+      return;
+    }
+    // TODO(dhruvsri): check if basePath should be set here
+    this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
+      {path},
+      patchRange
+    );
+  }
+
   _getLoggedIn(): Promise<boolean> {
     return this.restApiService.getLoggedIn();
   }
@@ -1117,7 +1149,7 @@
     );
 
     promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments(value.patchNum));
+    this._loadComments(value.patchNum);
 
     promises.push(this._getChangeEdit());
 
@@ -1130,17 +1162,6 @@
         this._initPatchRange();
         this._initCommitRange();
 
-        assertIsDefined(this._path, '_path');
-        if (!this._changeComments)
-          throw new Error('change comments must be defined');
-        assertIsDefined(this._patchRange, '_patchRange');
-
-        // TODO(dhruvsri): check if basePath should be set here
-        this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
-          {path: this._path},
-          this._patchRange
-        );
-
         const edit = r[4] as EditInfo | undefined;
         if (edit) {
           this.set(`_change.revisions.${edit.commit.commit}`, {
@@ -1558,15 +1579,18 @@
 
   _loadComments(patchSet?: PatchSetNum) {
     assertIsDefined(this._changeNum, '_changeNum');
-    return this.$.commentAPI
-      .loadAll(this._changeNum, patchSet)
-      .then(comments => {
-        this._changeComments = comments;
-      });
+    return this.commentsService.loadAll(this._changeNum, patchSet);
   }
 
-  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  @observe(
+    '_changeComments',
+    '_files.changeFilesByPath',
+    '_path',
+    '_patchRange',
+    '_projectConfig'
+  )
   _recomputeComments(
+    changeComments?: ChangeComments,
     files?: {[path: string]: FileInfo},
     path?: string,
     patchRange?: PatchRange,
@@ -1576,11 +1600,11 @@
     if (!path) return;
     if (!patchRange) return;
     if (!projectConfig) return;
-    if (!this._changeComments) return;
+    if (!changeComments) return;
 
     const file = files[path];
     if (file && file.old_path) {
-      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+      this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
         patchRange
       );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index ebcfa18..1960ced 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -21,7 +21,7 @@
 import {ChangeStatus} from '../../../constants/constants.js';
 import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
@@ -40,6 +40,7 @@
   suite('basic tests', () => {
     let element;
     let clock;
+    let diffCommentsStub;
 
     suiteSetup(() => {
       const kb = TestKeyboardShortcutBinder.push();
@@ -100,7 +101,8 @@
           Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve({}));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      diffCommentsStub = stubRestApi('getDiffComments');
+      diffCommentsStub.returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
@@ -114,32 +116,21 @@
         patchNum: 77,
         basePatchNum: 'PARENT',
       };
-      sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
-        _comments: {'/COMMIT_MSG': [
-          {
-            ...createComment(),
-            id: 'c1',
-            line: 10,
-            patch_set: 2,
-            path: '/COMMIT_MSG',
-          }, {
-            ...createComment(),
-            id: 'c3',
-            line: 10,
-            patch_set: 'PARENT',
-            path: '/COMMIT_MSG',
-          },
-        ]},
-        computeCommentThreadCount: () => {},
-        computeCommentsString: () => '',
-        computeUnresolvedNum: () => {},
-        getPaths: () => {},
-        getThreadsBySideForFile: () => [],
-        getCommentsForPath: _testOnly_getCommentsForPath,
-        findCommentById: _testOnly_findCommentById,
-
-      }));
-      await element._loadComments();
+      element._changeComments = new ChangeComments({'/COMMIT_MSG': [
+        {
+          ...createComment(),
+          id: 'c1',
+          line: 10,
+          patch_set: 2,
+          path: '/COMMIT_MSG',
+        }, {
+          ...createComment(),
+          id: 'c3',
+          line: 10,
+          patch_set: 'PARENT',
+          path: '/COMMIT_MSG',
+        },
+      ]});
       await flush();
     });
 
@@ -186,6 +177,22 @@
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
+        diffCommentsStub.returns(Promise.resolve({
+          '/COMMIT_MSG': [
+            {
+              ...createComment(),
+              id: 'c1',
+              line: 10,
+              patch_set: 2,
+              path: '/COMMIT_MSG',
+            }, {
+              ...createComment(),
+              id: 'c3',
+              line: 10,
+              patch_set: 'PARENT',
+              path: '/COMMIT_MSG',
+            },
+          ]}));
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
@@ -236,6 +243,22 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -265,6 +288,22 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -322,6 +361,22 @@
     });
 
     test('diff toast to go to latest is shown and not base', async () => {
+      diffCommentsStub.returns(Promise.resolve({
+        '/COMMIT_MSG': [
+          {
+            ...createComment(),
+            id: 'c1',
+            line: 10,
+            patch_set: 2,
+            path: '/COMMIT_MSG',
+          }, {
+            ...createComment(),
+            id: 'c3',
+            line: 10,
+            patch_set: 'PARENT',
+            path: '/COMMIT_MSG',
+          },
+        ]}));
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 9af7e05..89b8b4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -63,7 +63,7 @@
 
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
-    return commentApiWrapper.loadComments();
+    element.changeComments = new ChangeComments();
   });
 
   test('enabled/disabled options', () => {
@@ -217,11 +217,10 @@
         // Should be recomputed for each available patch
         sinon.stub(element, '_computeBaseDropdownContent');
         assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        commentApiWrapper.loadComments().then()
-            .then(() => {
-              assert.equal(element._computeBaseDropdownContent.callCount, 1);
-              done();
-            });
+        element.changeComments = new ChangeComments();
+        flush();
+        assert.equal(element._computeBaseDropdownContent.callCount, 1);
+        done();
       });
 
   test('_computePatchDropdownContent called when basePatchNum updates', () => {
@@ -248,33 +247,6 @@
     assert.equal(element._computePatchDropdownContent.callCount, 1);
   });
 
-  test('_computePatchDropdownContent called when comments update', done => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    // Should be recomputed for each available patch
-    sinon.stub(element, '_computePatchDropdownContent');
-    assert.equal(element._computePatchDropdownContent.callCount, 0);
-    commentApiWrapper.loadComments().then()
-        .then(() => {
-          done();
-        });
-  });
-
   test('_computePatchDropdownContent', () => {
     const availablePatches = [
       {num: 'edit', sha: '1'},
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 7e4edd6..f125bfa 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -48,7 +48,7 @@
   _loading = true;
 
   @property({type: String})
-  _filter = '';
+  _filter?: string;
 
   private readonly restApiService = appContext.restApiService;
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
index dd1faeb..95ce1ec 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -25,7 +25,6 @@
   </style>
   <gr-list-view
     filter="[[_filter]]"
-    items="false"
     offset="0"
     loading="[[_loading]]"
     path="/Documentation"
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
rename to polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index f5e47ca..9c196c7 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -15,15 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-documentation-search.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-documentation-search';
+import {GrDocumentationSearch} from './gr-documentation-search';
+import {page} from '../../../utils/page-wrapper-utils';
+import 'lodash/lodash';
+import {stubRestApi} from '../../../test/test-utils';
+import {ListViewParams} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {DocResult} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
-let counter;
+let counter: number;
 const documentationGenerator = () => {
   return {
     title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
@@ -32,10 +35,10 @@
 };
 
 suite('gr-documentation-search tests', () => {
-  let element;
-  let documentationSearches;
+  let element: GrDocumentationSearch;
+  let documentationSearches: DocResult[];
 
-  let value;
+  let value: ListViewParams;
 
   setup(() => {
     sinon.stub(page, 'show');
@@ -47,16 +50,23 @@
     setup(done => {
       documentationSearches = _.times(26, documentationGenerator);
       stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
-      element._paramsChanged(value).then(() => { flush(done); });
+        Promise.resolve(documentationSearches)
+      );
+      element._paramsChanged(value).then(() => {
+        flush(done);
+      });
     });
 
     test('test for test repo in the list', done => {
       flush(() => {
-        assert.equal(element._documentationSearches[0].title,
-            'Gerrit Code Review - REST API Developers Notes1');
-        assert.equal(element._documentationSearches[0].url,
-            'Documentation/dev-rest-api.html');
+        assert.equal(
+          element._documentationSearches![0].title,
+          'Gerrit Code Review - REST API Developers Notes1'
+        );
+        assert.equal(
+          element._documentationSearches![0].url,
+          'Documentation/dev-rest-api.html'
+        );
         done();
       });
     });
@@ -65,12 +75,12 @@
   suite('filter', () => {
     setup(() => {
       documentationSearches = _.times(25, documentationGenerator);
-      _.times(1, documentationSearches);
     });
 
     test('_paramsChanged', async () => {
       const stub = stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
+        Promise.resolve(documentationSearches)
+      );
       const value = {filter: 'test'};
       await element._paramsChanged(value);
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
@@ -84,7 +94,6 @@
       assert.equal(getComputedStyle(element.$.loading).display, 'block');
 
       element._loading = false;
-      element._repos = _.times(25, documentationGenerator);
 
       flush();
       assert.equal(element.computeLoadingClass(element._loading), '');
@@ -92,4 +101,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index e61d0b8..4163f9a 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -180,21 +180,19 @@
     }
   }
 
-  _closeDialog(dialog?: GrDialog, clearInputs = false) {
+  _closeDialog(dialog?: GrDialog) {
     if (!dialog) return;
 
-    if (clearInputs) {
-      // Dialog may have autocompletes and plain inputs -- as these have
-      // different properties representing their bound text, it is easier to
-      // just make two separate queries.
-      dialog.querySelectorAll('gr-autocomplete').forEach(input => {
-        input.text = '';
-      });
+    // Dialog may have autocompletes and plain inputs -- as these have
+    // different properties representing their bound text, it is easier to
+    // just make two separate queries.
+    dialog.querySelectorAll('gr-autocomplete').forEach(input => {
+      input.text = '';
+    });
 
-      dialog.querySelectorAll('iron-input').forEach(input => {
-        input.bindValue = '';
-      });
-    }
+    dialog.querySelectorAll('iron-input').forEach(input => {
+      input.bindValue = '';
+    });
 
     dialog.classList.toggle('invisible', true);
     return this.$.overlay.close();
@@ -211,12 +209,12 @@
       this.patchNum
     );
     GerritNav.navigateToRelativeUrl(url);
-    this._closeDialog(this._getDialogFromEvent(e), true);
+    this._closeDialog(this._getDialogFromEvent(e));
   }
 
   _handleUploadConfirm(path: string, fileData: string) {
     if (!this.change || !path || !fileData) {
-      this._closeDialog(this.$.openDialog, true);
+      this._closeDialog(this.$.openDialog);
       return Promise.resolve();
     }
     return this.restApiService
@@ -225,7 +223,7 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(this.$.openDialog, true);
+        this._closeDialog(this.$.openDialog);
         GerritNav.navigateToChange(this.change);
       });
   }
@@ -240,7 +238,7 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
@@ -253,7 +251,7 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
@@ -266,7 +264,7 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
+        this._closeDialog(dialog);
         GerritNav.navigateToChange(this.change);
       });
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index d9f5cd7..a0b5392 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -117,7 +117,7 @@
         assert.isFalse(editDiffStub.called);
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
@@ -202,7 +202,7 @@
         );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
@@ -296,8 +296,8 @@
         );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-        assert.equal(element._newPath, 'src/test.newPath');
+        assert.equal(element._path, '');
+        assert.equal(element._newPath, '');
       });
     });
   });
@@ -365,7 +365,7 @@
         );
         assert.isFalse(navStub.called);
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._path, '');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
deleted file mode 100644
index 180a3a4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-edit-constants.js';
-import './gr-edit-file-controls.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-
-const basicFixture = fixtureFromElement('gr-edit-file-controls');
-
-suite('gr-edit-file-controls tests', () => {
-  let element;
-
-  let fileActionHandler;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    fileActionHandler = sinon.stub();
-    element.addEventListener('file-action-tap', fileActionHandler);
-  });
-
-  test('open tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="open"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
-  });
-
-  test('delete tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="delete"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
-  });
-
-  test('restore tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="restore"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
-  });
-
-  test('rename tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="rename"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._allFileActions.length, 4);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
new file mode 100644
index 0000000..a432ebf
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-edit-file-controls';
+import {GrEditFileControls} from './gr-edit-file-controls';
+import {GrEditConstants} from '../gr-edit-constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-edit-file-controls');
+
+suite('gr-edit-file-controls tests', () => {
+  let element: GrEditFileControls;
+
+  let fileActionHandler: sinon.SinonStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    fileActionHandler = sinon.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+  });
+
+  test('open tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="open"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.OPEN.id,
+      path: 'foo',
+    });
+  });
+
+  test('delete tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="delete"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.DELETE.id,
+      path: 'foo',
+    });
+  });
+
+  test('restore tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="restore"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RESTORE.id,
+      path: 'foo',
+    });
+  });
+
+  test('rename tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="rename"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RENAME.id,
+      path: 'foo',
+    });
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index b0b7ab8..b724e72 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -168,7 +168,7 @@
 
   _hideAgreements(
     item: ContributorAgreementInfo,
-    groups: GroupInfo[],
+    groups?: GroupInfo[],
     signedAgreements?: ContributorAgreementInfo[]
   ) {
     return this._disableAgreements(item, groups, signedAgreements)
@@ -176,18 +176,18 @@
       : 'hide';
   }
 
-  _disableAgreementsText(text: string) {
-    return text.toLowerCase() === 'i agree' ? false : true;
+  _disableAgreementsText(text?: string) {
+    return text?.toLowerCase() === 'i agree' ? false : true;
   }
 
   // This checks for auto_verify_group,
   // if specified it returns 'hideAgreementsTextBox' which
   // then hides the text box and submit button.
   _computeHideAgreementClass(
-    name: string,
+    name?: string,
     contributorAgreements?: ContributorAgreementInfo[]
   ) {
-    if (!contributorAgreements) return '';
+    if (!name || !contributorAgreements) return '';
     return contributorAgreements.some(
       (contributorAgreement: ContributorAgreementInfo) =>
         name === contributorAgreement.name &&
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index 887382e..4800e5b 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -104,12 +104,7 @@
           bind-value="{{_agreementsText}}"
           placeholder="Enter 'I agree' here"
         >
-          <input
-            id="input-agreements"
-            is="iron-input"
-            bind-value="{{_agreementsText}}"
-            placeholder="Enter 'I agree' here"
-          />
+          <input id="input-agreements" placeholder="Enter 'I agree' here" />
         </iron-input>
         <gr-button
           on-click="_handleSaveAgreements"
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 5e8cedd..2ad3be6 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
@@ -92,12 +92,6 @@
    */
 
   /**
-   * Fired when a comment in the thread is permanently modified.
-   *
-   * @event thread-changed
-   */
-
-  /**
    * gr-comment-thread exposes the following attributes that allow a
    * diff widget like gr-diff to show the thread in the right location:
    *
@@ -718,7 +712,6 @@
     if (this.comments.length === 0) {
       this.fireRemoveSelf();
     }
-    this._handleCommentSavedOrDiscarded();
 
     // Check to see if there are any other open comments getting edited and
     // set the local storage value to its message value.
@@ -738,15 +731,6 @@
     }
   }
 
-  _handleCommentSavedOrDiscarded() {
-    this.dispatchEvent(
-      new CustomEvent('thread-changed', {
-        detail: {rootId: this.rootId, path: this.path},
-        bubbles: false,
-      })
-    );
-  }
-
   _handleCommentUpdate(e: CustomEvent) {
     const comment = e.detail.comment;
     const index = this._indexOf(comment, this.comments);
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
index 247e8e4..5c01467 100644
--- 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
@@ -165,7 +165,6 @@
           project-config="[[_projectConfig]]"
           on-create-fix-comment="_handleCommentFix"
           on-comment-discard="_handleCommentDiscard"
-          on-comment-save="_handleCommentSavedOrDiscarded"
           on-copy-comment-link="handleCopyLink"
         ></gr-comment>
       </template>
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 31908fd..82a455e 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
@@ -495,21 +495,10 @@
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
     assert.ok(commentEl);
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     element.shadowRoot?.querySelector('gr-comment')?._fireSave();
 
     flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.rootId,
-        'baf0414d_60047215'
-      );
       assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        '/path/to/file.txt'
-      );
       done();
     });
   });
@@ -556,22 +545,11 @@
     );
     flush();
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[1];
     assert.ok(draftEl);
     draftEl!.addEventListener('comment-discard', () => {
       const drafts = element.comments.filter(c => isDraft(c));
       assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.rootId,
-        element.rootId
-      );
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        element.path
-      );
       done();
     });
     draftEl!.dispatchEvent(
@@ -593,18 +571,10 @@
     const rootId = element.rootId;
     assert.isOk(rootId);
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[0];
     assert.ok(draftEl);
     draftEl!.addEventListener('comment-discard', () => {
       assert.equal(element.comments.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId, rootId);
-      assert.equal(
-        saveOrDiscardStub.lastCall.args[0].detail.path,
-        element.path
-      );
       done();
     });
     draftEl!.dispatchEvent(
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 5dea745..1303e48 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -273,6 +273,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly commentsService = appContext.commentsService;
+
   private fireUpdateTask?: DelayedTask;
 
   private storeTask?: DelayedTask;
@@ -486,6 +488,7 @@
           if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
+          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
           this.comment = resComment;
           this._fireSave();
           return obj;
@@ -542,6 +545,7 @@
   }
 
   _fireSave() {
+    if (this.comment) this.commentsService.addDraft(this.comment);
     this.dispatchEvent(
       new CustomEvent('comment-save', {
         detail: this._getEventPayload(),
@@ -743,6 +747,7 @@
   }
 
   _fireDiscard() {
+    if (this.comment) this.commentsService.deleteDraft(this.comment);
     this.fireUpdateTask?.cancel();
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 4f750dd..f74962a 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -26,6 +26,7 @@
 import {GrStorageService} from './storage/gr-storage_impl';
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -74,6 +75,7 @@
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
     changeService: () => new ChangeService(),
+    commentsService: () => new CommentsService(appContext.restApiService),
     checksService: () => new ChecksService(appContext.reportingService),
     jsApiService: () => new GrJsApiInterface(),
     storageService: () => new GrStorageService(),
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index de74f2a..161378d 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -25,6 +25,7 @@
 import {StorageService} from './storage/gr-storage';
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -33,6 +34,7 @@
   authService: AuthService;
   restApiService: RestApiService;
   changeService: ChangeService;
+  commentsService: CommentsService;
   checksService: ChecksService;
   jsApiService: JsApiService;
   storageService: StorageService;
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
new file mode 100644
index 0000000..e4a23a3
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -0,0 +1,135 @@
+/**
+ * @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 {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,
+} from '../../types/common';
+import {addPath, DraftInfo} from '../../utils/comment-util';
+
+interface CommentState {
+  comments: PathToCommentsInfoMap;
+  robotComments: {[path: string]: RobotCommentInfo[]};
+  drafts: {[path: string]: DraftInfo[]};
+  portedComments: PathToCommentsInfoMap;
+  portedDrafts: PathToCommentsInfoMap;
+}
+
+const initialState: CommentState = {
+  comments: {},
+  robotComments: {},
+  drafts: {},
+  portedComments: {},
+  portedDrafts: {},
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const commentState$: Observable<CommentState> = privateState$;
+
+export const drafts$ = commentState$.pipe(
+  map(commentState => commentState.drafts),
+  distinctUntilChanged()
+);
+
+// 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
+      )
+  ),
+  distinctUntilChanged()
+);
+
+export function updateStateComments(comments?: {
+  [path: string]: CommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.comments = addPath(comments) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateRobotComments(robotComments?: {
+  [path: string]: RobotCommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.robotComments = addPath(robotComments) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.drafts = addPath(drafts) || {};
+  privateState$.next(nextState);
+}
+
+export function updateStatePortedComments(
+  portedComments?: PathToCommentsInfoMap
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedComments = portedComments || {};
+  privateState$.next(nextState);
+}
+
+export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedDrafts = portedDrafts || {};
+  privateState$.next(nextState);
+}
+
+export function updateStateAddDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(
+    d => d.__draftID === draft.__draftID || d.id === draft.id
+  );
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  privateState$.next(nextState);
+}
+
+export function updateStateDeleteDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = drafts[draft.path].findIndex(
+    d => d.__draftID === draft.__draftID || d.id === draft.id
+  );
+  if (index === -1) return;
+  drafts[draft.path] = [...drafts[draft.path]].splice(index, 1);
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
new file mode 100644
index 0000000..05dfc4c
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 {NumericChangeId, RevisionId} from '../../types/common';
+import {DraftInfo} from '../../utils/comment-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {
+  updateStateAddDraft,
+  updateStateDeleteDraft,
+  updateStateComments,
+  updateStateRobotComments,
+  updateStateDrafts,
+  updateStatePortedComments,
+  updateStatePortedDrafts,
+} from './comments-model';
+
+export class CommentsService {
+  constructor(readonly restApiService: RestApiService) {}
+
+  /**
+   * Load all comments (with drafts and robot comments) for the given change
+   * number. The returned promise resolves when the comments have loaded, but
+   * does not yield the comment data.
+   */
+  // TODO(dhruvsri): listen to changeNum changes or reload event to update
+  // automatically
+  loadAll(changeNum: NumericChangeId, patchNum = CURRENT as RevisionId) {
+    const revision = patchNum;
+    this.restApiService
+      .getDiffComments(changeNum)
+      .then(comments => updateStateComments(comments));
+    this.restApiService
+      .getDiffRobotComments(changeNum)
+      .then(robotComments => updateStateRobotComments(robotComments));
+    this.restApiService
+      .getDiffDrafts(changeNum)
+      .then(drafts => updateStateDrafts(drafts));
+    this.restApiService
+      .getPortedComments(changeNum, revision)
+      .then(portedComments => updateStatePortedComments(portedComments));
+    this.restApiService
+      .getPortedDrafts(changeNum, revision)
+      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
+  }
+
+  addDraft(draft: DraftInfo) {
+    updateStateAddDraft(draft);
+  }
+
+  deleteDraft(draft: DraftInfo) {
+    updateStateDeleteDraft(draft);
+  }
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
new file mode 100644
index 0000000..604b5c4
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service_test.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma';
+import {
+  createComment,
+  createFixSuggestionInfo,
+} from '../../test/test-data-generators';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  NumericChangeId,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {appContext} from '../app-context';
+import {CommentsService} from './comments-service';
+
+suite('change service tests', () => {
+  let commentsService: CommentsService;
+
+  test('loads logged-out', () => {
+    const changeNum = 1234 as NumericChangeId;
+    commentsService = new CommentsService(appContext.restApiService);
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234 as NumericChangeId;
+
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+});
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
index 526aabe..fc4599d 100644
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -28,27 +28,6 @@
       _changeComments: Object,
     };
   }
-
-  loadComments() {
-    return this._reloadComments();
-  }
-
-  /**
-   * For the purposes of the mock, _reloadDrafts is not included because its
-   * response is the same type as reloadComments, just makes less API
-   * requests. Since this is for test purposes/mocked data anyway, keep this
-   * file simpler by just using _reloadComments here instead.
-   */
-  _reloadDraftsWithCallback(e) {
-    return this._reloadComments().then(() => e.detail.resolve());
-  }
-
-  _reloadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => {
-          this._changeComments = this.$.commentAPI._changeComments;
-        });
-  }
 }
 
 /**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index cc163c8..6e37697 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PatchSetNum, UrlEncodedCommentId} from './common';
+import {PatchSetNum} from './common';
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
@@ -23,9 +23,11 @@
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  CHANGE = 'change',
   CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
   DIALOG_CHANGE = 'dialog-change',
+  DROP = 'drop',
   EDITABLE_CONTENT_SAVE = 'editable-content-save',
   GR_RPC_LOG = 'gr-rpc-log',
   LOCATION_CHANGE = 'location-change',
@@ -45,19 +47,25 @@
   SHOW_ERROR = 'show-error',
   SHOW_PRIMARY_TAB = 'show-primary-tab',
   SHOW_SECONDARY_TAB = 'show-secondary-tab',
-  THREAD_LIST_MODIFIED = 'thread-list-modified',
+  TAP_ITEM = 'tap-item',
   TITLE_CHANGE = 'title-change',
 }
 
 declare global {
   interface HTMLElementEventMap {
     /* prettier-ignore */
+    'change': ChangeEvent;
+    /* prettier-ignore */
     'changed': ChangedEvent;
     'change-message-deleted': ChangeMessageDeletedEvent;
     'dialog-change': DialogChangeEvent;
+    /* prettier-ignore */
+    'drop': DropEvent;
     'editable-content-save': EditableContentSaveEvent;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    /* prettier-ignore */
+    'keypress': KeypressEvent;
     'line-mouse-enter': LineNumberEvent;
     'line-mouse-leave': LineNumberEvent;
     'line-cursor-moved-in': LineNumberEvent;
@@ -74,7 +82,7 @@
     'show-error': ShowErrorEvent;
     'show-primary-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
-    'thread-list-modified': ThreadListModifiedEvent;
+    'tap-item': TapItemEvent;
     'title-change': TitleChangeEvent;
   }
 }
@@ -92,6 +100,8 @@
   }
 }
 
+export type ChangeEvent = InputEvent;
+
 export type ChangedEvent = CustomEvent<string>;
 
 export interface ChangeMessageDeletedEventDetail {
@@ -107,6 +117,8 @@
 }
 export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
 
+export type DropEvent = DragEvent;
+
 export interface EditableContentSaveEventDetail {
   content: string;
 }
@@ -125,6 +137,8 @@
 }
 export type IronAnnounceEvent = CustomEvent<IronAnnounceEventDetail>;
 
+export type KeypressEvent = InputEvent;
+
 export interface LocationChangeEventDetail {
   hash: string;
   pathname: string;
@@ -217,11 +231,7 @@
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-export interface ThreadListModifiedDetail {
-  rootId: UrlEncodedCommentId;
-  path: string;
-}
-export type ThreadListModifiedEvent = CustomEvent<ThreadListModifiedDetail>;
+export type TapItemEvent = CustomEvent;
 
 export interface TitleChangeEventDetail {
   title: string;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 83a9174..3e85b48 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -361,3 +361,25 @@
   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
+ */
+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};
+      });
+    }
+  }
+  return updatedComments;
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 4ba27bc..c356893 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {UrlEncodedCommentId} from '../types/common';
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
@@ -108,17 +107,6 @@
   fire(target, EventType.IRON_ANNOUNCE, {text});
 }
 
-export function fireThreadListModifiedEvent(
-  target: EventTarget,
-  rootId: UrlEncodedCommentId,
-  path: string
-) {
-  fire(target, EventType.THREAD_LIST_MODIFIED, {
-    rootId,
-    path,
-  });
-}
-
 export function fireShowPrimaryTab(
   target: EventTarget,
   tab: string,