Refactor draft comment state

Do not model `UNSAVED` as part of the draft state. Rename and repurpose
the draft state to `savingState` only.

Don't use the term "unsaved". Use "new" instead. Instead of using
`state` for checking, if a draft is "new", inspect the `client_id` and
`id` properties.

Release-Notes: skip
Change-Id: I9c3bbefb6b2910ef7d7b29f3fc630adcf95bba8d
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 747a982..8a4eb01 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
@@ -76,7 +76,7 @@
   PatchSetNumber,
   CommentThread,
   ChangeStates,
-  DraftState,
+  SavingState,
 } from '../../../types/common';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
@@ -154,7 +154,7 @@
           updated: '2018-02-13 22:48:48.018000000' as Timestamp,
           message: 'draft',
           unresolved: false,
-          state: DraftState.SAVED,
+          savingState: SavingState.OK,
           patch_set: 2 as RevisionPatchSetNum,
         },
       ],
@@ -257,7 +257,7 @@
           updated: '2018-02-15 22:48:48.018000000' as Timestamp,
           message: 'resolved draft',
           unresolved: false,
-          state: DraftState.SAVED,
+          savingState: SavingState.OK,
           patch_set: 2 as RevisionPatchSetNum,
         },
       ],
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 0028380..1ed2729 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -34,7 +34,7 @@
   ReviewInputTag,
   Timestamp,
   UrlEncodedCommentId,
-  DraftState,
+  SavingState,
 } from '../../../types/common';
 import {ChangeMessageDeletedEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -776,7 +776,7 @@
               message: 'n',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              state: DraftState.SAVED,
+              savingState: SavingState.OK,
             },
           ],
           patchNum: 1 as RevisionPatchSetNum,
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index cb87f12..c0383fa 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -71,7 +71,7 @@
   queryAndAssert,
 } from '../../../utils/common-util';
 import {
-  createUnsavedPatchsetLevel,
+  createNewPatchsetLevel,
   getFirstComment,
   isPatchsetLevel,
   isUnresolved,
@@ -703,8 +703,8 @@
     if (!this.patchsetLevelComment && this.latestPatchNum) {
       // TODO: This should rather be done in the comments model. It should
       // ensure that a patchset level draft is always present.
-      this.getCommentsModel().addUnsavedDraft(
-        createUnsavedPatchsetLevel(
+      this.getCommentsModel().addNewDraft(
+        createNewPatchsetLevel(
           this.latestPatchNum,
           this.patchsetLevelDraftMessage,
           !this.patchsetLevelDraftIsResolved
@@ -949,7 +949,8 @@
   }
 
   private renderDraftsSection() {
-    if (this.computeHideDraftList(this.draftCommentThreads)) return;
+    const threads = this.draftCommentThreads;
+    if (!threads || threads.length === 0) return;
     return html`
       <section class="draftsContainer">
         <div class="includeComments">
@@ -960,17 +961,13 @@
             ?checked=${this.includeComments}
           />
           <label for="includeComments"
-            >Publish ${this.computeDraftsTitle(this.draftCommentThreads)}</label
+            >Publish ${this.computeDraftsTitle(threads)}</label
           >
         </div>
         ${when(
           this.includeComments,
           () => html`
-            <gr-thread-list
-              id="commentList"
-              .threads=${this.draftCommentThreads}
-              hide-dropdown
-            >
+            <gr-thread-list id="commentList" .threads=${threads} hide-dropdown>
             </gr-thread-list>
           `
         )}
@@ -1519,10 +1516,6 @@
     });
   }
 
-  computeHideDraftList(draftCommentThreads?: CommentThread[]) {
-    return !draftCommentThreads || draftCommentThreads.length === 0;
-  }
-
   computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
     return pluralize(total, 'Draft');
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 126343e..3e20d9a 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -57,7 +57,6 @@
 import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
-import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {fixture, html, waitUntil, assert} from '@open-wc/testing';
 import {accountKey} from '../../../utils/account-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -479,9 +478,6 @@
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
-    assert.isFalse(
-      queryAndAssert<GrThreadList>(element, '#commentList').hidden
-    );
   });
 
   test('modified attention set', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
index 4158f31..a4357bb 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -34,7 +34,7 @@
   RevisionPatchSetNum,
   CommentThread,
   isDraft,
-  DraftState,
+  SavingState,
 } from '../../../types/common';
 import {query, queryAndAssert} from '../../../utils/common-util';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
@@ -76,7 +76,7 @@
             updated: '2015-12-01 15:16:15.000000000' as Timestamp,
             message: 'draft',
             unresolved: true,
-            state: DraftState.SAVED,
+            savingState: SavingState.OK,
             patch_set: '2' as RevisionPatchSetNum,
           },
         ],
@@ -163,7 +163,7 @@
             updated: '2015-12-05 15:16:15.000000000' as Timestamp,
             message: 'resolved draft',
             unresolved: false,
-            state: DraftState.SAVED,
+            savingState: SavingState.OK,
             patch_set: '2' as RevisionPatchSetNum,
           },
         ],
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 511c9ab..2ccae8d 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
@@ -21,7 +21,7 @@
   isNumber,
 } from '../../../utils/patch-set-util';
 import {
-  createUnsaved,
+  createNew,
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
@@ -1141,7 +1141,7 @@
 
     const parentIndex = this.computeParentIndex();
     const draft: DraftInfo = {
-      ...createUnsaved('', true),
+      ...createNew('', true),
       patch_set: patchNum as RevisionPatchSetNum,
       side: commentSide,
       parent: parentIndex ?? undefined,
@@ -1149,7 +1149,7 @@
       line: typeof lineNum === 'number' ? lineNum : undefined,
       range,
     };
-    this.getCommentsModel().addUnsavedDraft(draft);
+    this.getCommentsModel().addNewDraft(draft);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 5de86aa..43045f7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -1008,7 +1008,7 @@
 
     setup(async () => {
       const commentsModel: CommentsModel = testResolver(commentsModelToken);
-      addDraftSpy = sinon.spy(commentsModel, 'addUnsavedDraft');
+      addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
 
       account = createAccountDetailWithId(1);
       element.disconnectedCallback();
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 3ec0764..c76f04c 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
@@ -21,7 +21,7 @@
   computeDiffFromContext,
   getLastComment,
   getFirstComment,
-  createUnsavedReply,
+  createNewReply,
   NEWLINE_PATTERN,
   id,
 } from '../../../utils/comment-util';
@@ -38,9 +38,7 @@
   CommentRange,
   CommentThread,
   isDraft,
-  isDraftOrUnsaved,
   isRobot,
-  isUnsaved,
   NumericChangeId,
   RepoName,
   UrlEncodedCommentId,
@@ -50,7 +48,11 @@
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {assertIsDefined, copyToClipbard} from '../../../utils/common-util';
+import {
+  assert,
+  assertIsDefined,
+  copyToClipbard,
+} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
@@ -468,26 +470,26 @@
   renderComments() {
     assertIsDefined(this.thread, 'thread');
     const publishedComments = repeat(
-      this.thread.comments.filter(c => !isDraftOrUnsaved(c)),
+      this.thread.comments.filter(c => !isDraft(c)),
       comment => comment.id,
       comment => this.renderComment(comment)
     );
     // We are deliberately not including the draft in the repeat directive,
     // because we ran into spurious issues with <gr-comment> being destroyed
     // and re-created when an unsaved draft transitions to 'saved' state.
-    // TODO: Revisit this, because UNSAVED and DRAFT is not so different
+    // TODO: Revisit this, because this transition should not cause issues
     // anymore. Just put the draft into the `repeat` directive above and
-    // then use `id()` instead of`.id` above.
-    const draftComment = this.renderComment(this.getDraftOrUnsaved());
+    // then use `id()` instead of `.id` above.
+    const draftComment = this.renderComment(this.getDraft());
     return html`${publishedComments}${draftComment}`;
   }
 
   private renderComment(comment?: Comment) {
     if (!comment) return nothing;
-    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const robotButtonDisabled = !this.account || this.isDraft();
     const isFirstComment = this.getFirstComment() === comment;
     const initiallyCollapsed =
-      !isDraftOrUnsaved(comment) &&
+      !isDraft(comment) &&
       (this.messageId
         ? comment.change_message_id !== this.messageId
         : !this.unresolved);
@@ -504,18 +506,17 @@
         @comment-editing-changed=${(
           e: CustomEvent<CommentEditingChangedDetail>
         ) => {
-          if (isDraftOrUnsaved(comment)) this.editing = e.detail.editing;
+          if (isDraft(comment)) this.editing = e.detail.editing;
         }}
         @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
-          if (isDraftOrUnsaved(comment)) this.unresolved = e.detail.value;
+          if (isDraft(comment)) this.unresolved = e.detail.value;
         }}
       ></gr-comment>
     `;
   }
 
   renderActions() {
-    if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
-      return;
+    if (!this.account || this.isDraft() || this.isRobotComment()) return;
     return html`
       <div id="actionsContainer">
         <span id="unresolvedLabel">${
@@ -617,13 +618,13 @@
     if (changed.has('thread')) {
       assertIsDefined(this.thread, 'thread');
       assertIsDefined(this.getFirstComment(), 'first comment');
-      if (!this.isDraftOrUnsaved()) {
+      if (!this.isDraft()) {
         // We can only do this for threads without draft, because otherwise we
         // are relying on the <gr-comment> component for the draft to fire
         // events about the *dirty* `unresolved` state.
         this.unresolved = this.getLastComment()?.unresolved ?? true;
       }
-      this.hasDraft = this.isDraftOrUnsaved();
+      this.hasDraft = this.isDraft();
       this.rootId = id(this.getFirstComment()!);
     }
     if (changed.has('editing')) {
@@ -651,19 +652,11 @@
     return isDraft(this.getLastComment());
   }
 
-  private isDraftOrUnsaved(): boolean {
-    return this.isDraft() || this.isUnsaved();
-  }
-
-  private getDraftOrUnsaved(): Comment | undefined {
-    if (this.isDraftOrUnsaved()) return this.getLastComment();
+  private getDraft(): Comment | undefined {
+    if (this.isDraft()) return this.getLastComment();
     return undefined;
   }
 
-  private isUnsaved(): boolean {
-    return isUnsaved(this.getLastComment());
-  }
-
   private isPatchsetLevel() {
     return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
@@ -800,19 +793,14 @@
     const replyingTo = this.getLastComment();
     assertIsDefined(this.thread, 'thread');
     assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
-    if (isDraft(replyingTo)) {
-      throw new Error('cannot reply to draft');
-    }
-    if (isUnsaved(replyingTo)) {
-      throw new Error('cannot reply to unsaved comment');
-    }
-    const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+    assert(!isDraft(replyingTo), 'cannot reply to draft');
+    const newReply = createNewReply(replyingTo, content, unresolved);
     if (userWantsToEdit) {
-      this.getCommentsModel().addUnsavedDraft(unsaved);
+      this.getCommentsModel().addNewDraft(newReply);
     } else {
       try {
         this.saving = true;
-        await this.getCommentsModel().saveDraft(unsaved);
+        await this.getCommentsModel().saveDraft(newReply);
       } finally {
         this.saving = false;
       }
@@ -848,7 +836,7 @@
     const author = this.getFirstComment()?.author ?? this.account;
     const user = getUserName(undefined, author);
     const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
-    const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+    const draftStatus = this.isDraft() ? 'Draft ' : '';
     return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
   }
 }
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 8d1f24c..1027a62 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -14,7 +14,7 @@
   CommentInfo,
   RepoName,
   DraftInfo,
-  DraftState,
+  SavingState,
 } from '../../../types/common';
 import {
   mockPromise,
@@ -26,7 +26,7 @@
 import {
   createAccountDetailWithId,
   createThread,
-  createUnsaved,
+  createNewDraft,
 } from '../../../test/test-data-generators';
 import {SinonStubbedMember} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -39,14 +39,14 @@
 } from '../../../models/comments/comments-model';
 import {testResolver} from '../../../test/common-test-setup';
 
-const c1 = {
+const c1: CommentInfo = {
   author: {name: 'Kermit'},
   id: 'the-root' as UrlEncodedCommentId,
   message: 'start the conversation',
   updated: '2021-11-01 10:11:12.000000000' as Timestamp,
 };
 
-const c2 = {
+const c2: CommentInfo = {
   author: {name: 'Ms Piggy'},
   id: 'the-reply' as UrlEncodedCommentId,
   message: 'keep it going',
@@ -54,13 +54,13 @@
   in_reply_to: 'the-root' as UrlEncodedCommentId,
 };
 
-const c3 = {
+const c3: DraftInfo = {
   author: {name: 'Kermit'},
   id: 'the-draft' as UrlEncodedCommentId,
   message: 'stop it',
   updated: '2021-11-03 10:11:12.000000000' as Timestamp,
   in_reply_to: 'the-reply' as UrlEncodedCommentId,
-  state: DraftState.SAVED,
+  savingState: SavingState.OK,
 };
 
 const commentWithContext = {
@@ -128,7 +128,7 @@
   });
 
   test('renders unsaved', async () => {
-    element.thread = createThread(createUnsaved());
+    element.thread = createThread(createNewDraft());
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
@@ -316,14 +316,14 @@
   suite('action button clicks', () => {
     let savePromise: MockPromise<DraftInfo>;
     let stubSave: SinonStubbedMember<CommentsModel['saveDraft']>;
-    let stubAdd: SinonStubbedMember<CommentsModel['addUnsavedDraft']>;
+    let stubAdd: SinonStubbedMember<CommentsModel['addNewDraft']>;
 
     setup(async () => {
       savePromise = mockPromise<DraftInfo>();
       stubSave = sinon
         .stub(testResolver(commentsModelToken), 'saveDraft')
         .returns(savePromise);
-      stubAdd = sinon.stub(testResolver(commentsModelToken), 'addUnsavedDraft');
+      stubAdd = sinon.stub(testResolver(commentsModelToken), 'addNewDraft');
 
       element.thread = createThread(c1, {...c2, unresolved: true});
       await element.updateComplete;
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 ade21a2..d790e8c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -26,13 +26,11 @@
   RepoName,
   RobotCommentInfo,
   Comment,
-  isDraftOrUnsaved,
   isRobot,
-  isUnsaved,
   isSaving,
   isError,
-  isSaved,
   isDraft,
+  isNew,
 } from '../../../types/common';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
@@ -67,7 +65,6 @@
 import {createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
-import {when} from 'lit/directives/when.js';
 
 const FILE = 'FILE';
 
@@ -494,12 +491,11 @@
 
   override render() {
     if (!this.comment) return;
-    if (isUnsaved(this.comment) && !this.editing) return;
-    this.toggleAttribute('saving', this.isSaving());
-    this.toggleAttribute('error', this.isError());
+    this.toggleAttribute('saving', isSaving(this.comment));
+    this.toggleAttribute('error', isError(this.comment));
     const classes = {
       container: true,
-      draft: isDraftOrUnsaved(this.comment),
+      draft: isDraft(this.comment),
     };
     return html`
       <gr-endpoint-decorator name="comment">
@@ -509,10 +505,7 @@
         </gr-endpoint-param>
         <gr-endpoint-param name="message" .value=${this.messageText}>
         </gr-endpoint-param>
-        <gr-endpoint-param
-          name="isDraft"
-          .value=${isDraftOrUnsaved(this.comment)}
-        >
+        <gr-endpoint-param name="isDraft" .value=${isDraft(this.comment)}>
         </gr-endpoint-param>
         <div id="container" class=${classMap(classes)}>
           ${this.renderHeader()}
@@ -549,17 +542,13 @@
   }
 
   private renderAuthor() {
-    if (isDraftOrUnsaved(this.comment)) return;
+    if (isDraft(this.comment)) return;
     if (isRobot(this.comment)) {
       const id = this.comment.robot_id;
       return html`<span class="robotName">${id}</span>`;
     }
-    const classes = {draft: isDraftOrUnsaved(this.comment)};
     return html`
-      <gr-account-label
-        .account=${this.comment?.author ?? this.account}
-        class=${classMap(classes)}
-      >
+      <gr-account-label .account=${this.comment?.author ?? this.account}>
       </gr-account-label>
     `;
   }
@@ -577,13 +566,13 @@
   }
 
   private renderDraftLabel() {
-    if (!isDraftOrUnsaved(this.comment)) return;
+    if (!isDraft(this.comment)) return;
     let label = 'Draft';
     let tooltip =
       'This draft is only visible to you. ' +
       "To publish drafts, click the 'Reply' or 'Start review' button " +
       "at the top of the change or press the 'a' key.";
-    if (this.isError()) {
+    if (isError(this.comment)) {
       label += ' (Failed to save)';
       tooltip = 'Unable to save draft. Please try to save again.';
     }
@@ -626,12 +615,7 @@
    * a draft. It is an action applied to published comments.
    */
   private renderDeleteButton() {
-    if (
-      !this.isAdmin ||
-      isDraftOrUnsaved(this.comment) ||
-      isRobot(this.comment)
-    )
-      return;
+    if (!this.isAdmin || isDraft(this.comment) || isRobot(this.comment)) return;
     if (this.collapsed) return;
     return html`
       <gr-button
@@ -661,29 +645,32 @@
     // This should match the condition of `renderPatchset()`.
     if (!this.showPatchset) return;
     // This should match the condition of `renderDate()`.
-    if (!this.comment?.updated || this.collapsed) return;
+    if (this.collapsed) return;
     // Render separator, if both are present: patchset AND date.
     return html`<span class="separator"></span>`;
   }
 
   private renderDate() {
-    if (this.isUnsaved() || this.collapsed) return;
+    if (this.collapsed) return;
     return html`
       <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
-        ${when(
-          this.isSaved(),
-          () => html`
-            <gr-date-formatter
-              withTooltip
-              .dateStr=${this.comment!.updated}
-            ></gr-date-formatter>
-          `,
-          () => (this.comment as DraftInfo).state
-        )}
+        ${this.renderDateInner()}
       </span>
     `;
   }
 
+  private renderDateInner() {
+    if (isError(this.comment)) return 'Error';
+    if (isSaving(this.comment)) return 'Saving';
+    if (isNew(this.comment)) return 'New';
+    return html`
+      <gr-date-formatter
+        withTooltip
+        .dateStr=${this.comment!.updated}
+      ></gr-date-formatter>
+    `;
+  }
+
   private renderToggle() {
     const icon = this.collapsed ? 'expand_more' : 'expand_less';
     const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
@@ -746,7 +733,7 @@
 
   private renderCopyLinkIcon() {
     // Only show the icon when the thread contains a published comment.
-    if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+    if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
     return html`
       <gr-icon
         icon="link"
@@ -761,7 +748,7 @@
 
   private renderHumanActions() {
     if (!this.account || isRobot(this.comment)) return;
-    if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+    if (this.collapsed || !isDraft(this.comment)) return;
     return html`
       <div class="actions">
         <div class="action resolve">
@@ -781,7 +768,7 @@
   }
 
   private renderDraftActions() {
-    if (!isDraftOrUnsaved(this.comment)) return;
+    if (!isDraft(this.comment)) return;
     return html`
       <div class="rightActions">
         ${this.renderDiscardButton()} ${this.renderEditButton()}
@@ -822,7 +809,7 @@
     if (this.editing || this.permanentEditingMode) return;
     return html`<gr-button
       link
-      ?disabled=${this.isSaving() && !this.autoSaving}
+      ?disabled=${isSaving(this.comment) && !this.autoSaving}
       class="action discard"
       @click=${this.discard}
       >Discard</gr-button
@@ -841,7 +828,7 @@
     return html`
       <gr-button
         link
-        ?disabled=${this.isSaving() && !this.autoSaving}
+        ?disabled=${isSaving(this.comment) && !this.autoSaving}
         class="action cancel"
         @click=${this.cancel}
         >Cancel</gr-button
@@ -850,7 +837,7 @@
   }
 
   private renderSaveButton() {
-    if (!this.editing && !this.isError()) return;
+    if (!this.editing) return;
     return html`
       <gr-button
         link
@@ -933,12 +920,20 @@
 
   firstWillUpdate() {
     if (this.firstWillUpdateDone) return;
-    this.firstWillUpdateDone = true;
-    if (this.permanentEditingMode) this.editing = true;
     assertIsDefined(this.comment, 'comment');
+    this.firstWillUpdateDone = true;
     this.unresolved = this.comment.unresolved ?? true;
-    if (isUnsaved(this.comment)) this.editing = true;
-    if (isDraftOrUnsaved(this.comment)) {
+    if (this.permanentEditingMode) {
+      this.edit();
+    }
+    if (
+      isDraft(this.comment) &&
+      isNew(this.comment) &&
+      !isSaving(this.comment)
+    ) {
+      this.edit();
+    }
+    if (isDraft(this.comment)) {
       this.collapsed = false;
     } else {
       this.collapsed = !!this.initiallyCollapsed;
@@ -955,6 +950,11 @@
 
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
+    if (changed.has('comment')) {
+      if (isDraft(this.comment) && isError(this.comment)) {
+        this.edit();
+      }
+    }
     if (changed.has('editing')) {
       this.onEditingChanged();
     }
@@ -984,9 +984,7 @@
 
   /** Enter editing mode. */
   private edit() {
-    if (!isDraftOrUnsaved(this.comment)) {
-      throw new Error('Cannot edit published comment.');
-    }
+    assert(isDraft(this.comment), 'only drafts are editable');
     if (this.editing) return;
     this.editing = true;
   }
@@ -1047,8 +1045,10 @@
       this.collapsed = false;
       this.messageText = this.comment?.message ?? '';
       this.unresolved = this.comment?.unresolved ?? true;
-      this.originalMessage = this.messageText;
-      this.originalUnresolved = this.unresolved;
+      if (!isError(this.comment) && !isSaving(this.comment)) {
+        this.originalMessage = this.messageText;
+        this.originalUnresolved = this.unresolved;
+      }
     }
 
     // Parent components such as the reply dialog might be interested in whether
@@ -1062,7 +1062,7 @@
   // private, but visible for testing
   isSaveDisabled() {
     assertIsDefined(this.comment, 'comment');
-    if (this.isSaving() && !this.autoSaving) return true;
+    if (isSaving(this.comment) && !this.autoSaving) return true;
     return !this.messageText?.trimEnd();
   }
 
@@ -1149,18 +1149,16 @@
   // private, but visible for testing
   cancel() {
     assertIsDefined(this.comment, 'comment');
-    if (!isDraftOrUnsaved(this.comment)) {
-      throw new Error('only unsaved and draft comments are editable');
-    }
+    assert(isDraft(this.comment), 'only drafts are editable');
     this.messageText = this.originalMessage;
     this.unresolved = this.originalUnresolved;
     this.save();
   }
 
   async autoSave() {
-    if (this.isSaving() || this.autoSaving) return;
+    if (isSaving(this.comment) || this.autoSaving) return;
     if (!this.editing || !this.comment) return;
-    if (!isDraftOrUnsaved(this.comment)) return;
+    assert(isDraft(this.comment), 'only drafts are editable');
     const messageToSave = this.messageText.trimEnd();
     if (messageToSave === '') return;
     if (messageToSave === this.comment.message) return;
@@ -1179,11 +1177,11 @@
   }
 
   async save() {
-    assert(isDraftOrUnsaved(this.comment), 'can only save drafts');
+    assert(isDraft(this.comment), 'only drafts are editable');
     // There is a minimal chance of `isSaving()` being false between iterations
     // of the below while loop. But this will be extremely rare and just lead
     // to a harmless assertion error. So let's not bother.
-    if (this.isSaving() && !this.autoSaving) return;
+    if (isSaving(this.comment) && !this.autoSaving) return;
 
     if (!this.permanentEditingMode) {
       this.editing = false;
@@ -1200,6 +1198,7 @@
       // No need to make a backend call when nothing has changed.
       while (this.somethingToSave()) {
         this.comment = await this.rawSave({showToast: true});
+        if (isError(this.comment)) return;
       }
     }
   }
@@ -1207,6 +1206,7 @@
   private somethingToSave() {
     if (!this.comment) return false;
     return (
+      isError(this.comment) ||
       this.messageText.trimEnd() !== this.comment?.message ||
       this.unresolved !== this.comment.unresolved
     );
@@ -1214,8 +1214,8 @@
 
   /** For sharing between save() and autoSave(). */
   private rawSave(options: {showToast: boolean}) {
-    assert(isDraftOrUnsaved(this.comment), 'can only save drafts');
-    assert(!this.isSaving(), 'saving already in progress');
+    assert(isDraft(this.comment), 'only drafts are editable');
+    assert(!isSaving(this.comment), 'saving already in progress');
     return this.getCommentsModel().saveDraft(
       {
         ...this.comment,
@@ -1268,23 +1268,6 @@
     );
     this.closeDeleteCommentModal();
   }
-
-  isUnsaved() {
-    return isUnsaved(this.comment);
-  }
-
-  isSaved() {
-    return !isDraft(this.comment) || isSaved(this.comment);
-  }
-
-  /** Note that this can also mean that auto-saving is in-progress. */
-  isSaving() {
-    return isSaving(this.comment);
-  }
-
-  isError() {
-    return isError(this.comment);
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 8299516..59485e2 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -22,7 +22,7 @@
 import {
   AccountId,
   DraftInfo,
-  DraftState,
+  SavingState,
   EmailAddress,
   NumericChangeId,
   PatchSetNum,
@@ -33,7 +33,7 @@
   createComment,
   createDraft,
   createRobotComment,
-  createUnsaved,
+  createNewDraft,
 } from '../../../test/test-data-generators';
 import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -273,7 +273,7 @@
 
     test('renders draft', async () => {
       element.initiallyCollapsed = false;
-      (element.comment as DraftInfo).state = DraftState.SAVED;
+      (element.comment as DraftInfo).savingState = SavingState.OK;
       await element.updateComplete;
       assert.shadowDom.equal(
         element,
@@ -353,7 +353,7 @@
 
     test('renders draft in editing mode', async () => {
       element.initiallyCollapsed = false;
-      (element.comment as DraftInfo).state = DraftState.SAVED;
+      (element.comment as DraftInfo).savingState = SavingState.OK;
       element.editing = true;
       await element.updateComplete;
       assert.shadowDom.equal(
@@ -467,7 +467,7 @@
       },
       line: 5,
       path: 'test',
-      state: DraftState.SAVED,
+      savingState: SavingState.OK,
       message: 'hello world',
     };
     element.editing = true;
@@ -492,7 +492,7 @@
       },
       line: 5,
       path: 'test',
-      state: DraftState.SAVED,
+      savingState: SavingState.OK,
       message: 'hello world',
     };
     element.editing = true;
@@ -542,7 +542,7 @@
       element.changeNum = 42 as NumericChangeId;
       element.comment = {
         ...createComment(),
-        state: DraftState.SAVED,
+        savingState: SavingState.OK,
         path: '/path/to/file',
         line: 5,
       };
@@ -565,7 +565,7 @@
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
 
-      element.comment = {...element.comment, state: DraftState.SAVING};
+      element.comment = {...element.comment, savingState: SavingState.SAVING};
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
     });
@@ -625,22 +625,28 @@
     test('save failed', async () => {
       sinon.stub(commentsModel, 'saveDraft').returns(
         Promise.resolve({
-          ...createUnsaved(),
+          ...createNewDraft(),
           message: 'something, not important',
-          state: DraftState.ERROR,
+          unresolved: true,
+          savingState: SavingState.ERROR,
         })
       );
 
-      element.comment = createUnsaved();
+      element.comment = createNewDraft({
+        message: '',
+        unresolved: true,
+      });
+      element.unresolved = true;
       element.editing = true;
       await element.updateComplete;
       element.messageText = 'something, not important';
       await element.updateComplete;
 
       element.save();
+      assert.isFalse(element.editing);
 
       await waitUntil(() => element.hasAttribute('error'));
-      assert.isFalse(element.editing);
+      assert.isTrue(element.editing);
     });
 
     test('discard', async () => {
@@ -670,7 +676,7 @@
       const saveStub = sinon.stub(commentsModel, 'saveDraft');
       element.comment = {
         ...createComment(),
-        state: DraftState.SAVED,
+        savingState: SavingState.OK,
         unresolved: false,
       };
       await element.updateComplete;
@@ -751,7 +757,7 @@
       savePromise = mockPromise<DraftInfo>();
       saveStub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
 
-      element.comment = createUnsaved();
+      element.comment = createNewDraft();
       element.editing = true;
       await element.updateComplete;
     });
@@ -797,14 +803,14 @@
 
       autoSavePromise.resolve({
         ...element.comment,
-        state: DraftState.SAVED,
+        savingState: SavingState.OK,
         message: 'auto save text',
         id: 'exp123' as UrlEncodedCommentId,
         updated: '2018-02-13 22:48:48.018000000' as Timestamp,
       });
       savePromise.resolve({
         ...element.comment,
-        state: DraftState.SAVED,
+        savingState: SavingState.OK,
         message: 'actual save text',
         id: 'exp123' as UrlEncodedCommentId,
         updated: '2018-02-13 22:48:49.018000000' as Timestamp,
@@ -829,7 +835,7 @@
         },
         line: 5,
         path: 'test',
-        state: DraftState.SAVED,
+        savingState: SavingState.OK,
         message: 'hello world',
       };
       element = await fixture(
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 31d4e2b..026e5e5 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -23,7 +23,7 @@
 } from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
-import {PROVIDED_FIX_ID, createUnsaved} from '../../utils/comment-util';
+import {PROVIDED_FIX_ID, createNew} from '../../utils/comment-util';
 import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
 import {CheckResult, CheckRun, RunResult} from './checks-model';
@@ -101,7 +101,7 @@
   const pointer = result.codePointers?.[0];
   assertIsDefined(pointer, 'codePointer');
   return {
-    ...createUnsaved(pleaseFixMessage(result), true),
+    ...createNew(pleaseFixMessage(result), true),
     path: pointer.path,
     patch_set: result.patchset as RevisionPatchSetNum,
     side: CommentSide.REVISION,
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index e74647b..2c18a09 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -15,14 +15,15 @@
   AccountInfo,
   DraftInfo,
   Comment,
-  isDraftOrUnsaved,
-  isUnsaved,
-  DraftState,
+  SavingState,
   isSaving,
   isError,
+  isDraft,
+  isNew,
 } from '../../types/common';
 import {
   addPath,
+  createNew,
   id,
   isDraftThread,
   reportingDetails,
@@ -36,7 +37,7 @@
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {ChangeModel} from '../change/change-model';
 import {Interaction, Timing} from '../../constants/reporting';
-import {assert, assertIsDefined, uuid} from '../../utils/common-util';
+import {assert, assertIsDefined} from '../../utils/common-util';
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {pluralize} from '../../utils/string-util';
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
@@ -215,8 +216,8 @@
 /** Adds or updates a draft. */
 export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
   const nextState = {...state};
-  if (!draft.path) throw new Error('draft path undefined');
-  if (!isDraftOrUnsaved(draft)) throw new Error('draft is not a draft');
+  assert(!!draft.path, 'draft without path');
+  assert(isDraft(draft), 'draft is not a draft');
 
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
@@ -236,8 +237,8 @@
   draft: DraftInfo
 ): CommentState {
   const nextState = {...state};
-  if (!draft.path) throw new Error('draft path undefined');
-  if (!isDraftOrUnsaved(draft)) throw new Error('draft is not a draft');
+  assert(!!draft.path, 'draft without path');
+  assert(isDraft(draft), 'draft is not a draft');
 
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
@@ -576,25 +577,22 @@
   async restoreDraft(draftId: UrlEncodedCommentId) {
     const found = this.discardedDrafts?.find(d => id(d) === draftId);
     if (!found) throw new Error('discarded draft not found');
-    const newDraft = {
+    const newDraft: DraftInfo = {
       ...found,
-      id: undefined,
-      updated: undefined,
-      state: DraftState.UNSAVED,
-      client_id: uuid() as UrlEncodedCommentId,
+      ...createNew(),
     };
     await this.saveDraft(newDraft);
     this.modifyState(s => deleteDiscardedDraft(s, draftId));
   }
 
   /**
-   * Adds a new unsaved draft without saving it.
+   * Adds a new draft without saving it.
    *
-   * There is no equivalent `removeUnsavedDraft()` method, because
+   * There is no equivalent `removeNewDraft()` method, because
    * `discardDraft()` can be used.
    */
-  addUnsavedDraft(draft: DraftInfo) {
-    assert(isUnsaved(draft), 'draft must be unsaved');
+  addNewDraft(draft: DraftInfo) {
+    assert(isNew(draft), 'draft must be new');
     this.modifyState(s => setDraft(s, draft));
   }
 
@@ -609,10 +607,10 @@
     assertIsDefined(this.changeNum, 'change number');
     assertIsDefined(draft.patch_set, 'patchset number of comment draft');
     assert(!!draft.message?.trim(), 'cannot save empty draft');
-    assert(draft.state !== DraftState.SAVING, 'saving already in progress');
+    assert(!isSaving(draft), 'saving already in progress');
 
     // optimistic update
-    const draftSaving: DraftInfo = {...draft, state: DraftState.SAVING};
+    const draftSaving: DraftInfo = {...draft, savingState: SavingState.SAVING};
     this.modifyState(s => setDraft(s, draftSaving));
 
     // Saving the change number as to make sure that the response is still
@@ -620,7 +618,7 @@
     const changeNum = this.changeNum;
     this.report(Interaction.SAVE_COMMENT, draft);
     if (showToast) this.showStartRequest();
-    const timing = isUnsaved(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
+    const timing = isNew(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
     const timer = this.reporting.getTimer(timing);
 
     let savedComment;
@@ -637,16 +635,16 @@
       )) as unknown as CommentInfo;
     } catch (error) {
       if (showToast) this.handleFailedDraftRequest();
-      const draftError: DraftInfo = {...draft, state: DraftState.ERROR};
+      const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
       this.modifyState(s => setDraft(s, draftError));
       return draftError;
     }
 
-    const draftSaved = {
+    const draftSaved: DraftInfo = {
       ...draft,
       id: savedComment.id,
       updated: savedComment.updated,
-      state: DraftState.SAVED,
+      savingState: SavingState.OK,
     };
     timer.end({id: draftSaved.id});
     if (showToast) this.showEndRequest();
@@ -659,14 +657,13 @@
     const draft = this.lookupDraft(draftId);
     assertIsDefined(draft, `draft not found by id ${draftId}`);
     assertIsDefined(draft.patch_set, 'patchset number of comment draft');
-    assert(draft.state !== DraftState.SAVING, 'saving already in progress');
+    assert(!isSaving(draft), 'saving already in progress');
 
     // optimistic update
     this.modifyState(s => deleteDraft(s, draft));
 
     // For "unsaved" drafts there is nothing to discard on the server side.
-    if (draft.state !== DraftState.UNSAVED) {
-      assertIsDefined(draft.id, 'missing id');
+    if (draft.id) {
       if (!draft.message?.trim()) throw new Error('empty draft');
       // Saving the change number as to make sure that the response is still
       // relevant when it comes back. The user maybe have navigated away.
@@ -709,9 +706,7 @@
     reason: string
   ) {
     assertIsDefined(comment.patch_set, 'comment.patch_set');
-    if (isDraftOrUnsaved(comment)) {
-      throw new Error('Admin deletion is only for published comments.');
-    }
+    assert(!isDraft(comment), 'Admin deletion is only for published comments.');
 
     const newComment = await this.restApiService.deleteComment(
       changeNum,
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index a0a1ebd..a92e932 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -71,7 +71,7 @@
   CommentThread,
   DraftInfo,
   ChangeMessage,
-  DraftState,
+  SavingState,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -92,7 +92,7 @@
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
-import {createCommentThreads} from '../utils/comment-util';
+import {createCommentThreads, createNew} from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
@@ -113,7 +113,6 @@
 import {RepoDetailView, RepoViewState} from '../models/views/repo';
 import {AdminChildView, AdminViewState} from '../models/views/admin';
 import {DashboardViewState} from '../models/views/dashboard';
-import {uuid} from '../utils/common-util';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -843,19 +842,16 @@
 export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    state: DraftState.SAVED,
+    savingState: SavingState.OK,
     ...extra,
   };
 }
 
-export function createUnsaved(extra: Partial<CommentInfo> = {}): DraftInfo {
+export function createNewDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    state: DraftState.UNSAVED,
-    client_id: uuid() as UrlEncodedCommentId,
-    id: undefined,
-    updated: undefined,
     ...extra,
+    ...createNew(),
   };
 }
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 4688b1f..b148780 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -695,27 +695,19 @@
   WIP = 'WIP',
 }
 
-export enum DraftState {
+export enum SavingState {
   /**
-   * New draft created in this session and not yet saved.
-   * Must have a `client_id` set.
-   */
-  // Possible prior states: -
-  // Possible subsequent states: SAVING
-  UNSAVED = 'UNSAVED',
-  /**
-   * Currently saving to the backend.
-   */
-  // Possible prior states: UNSAVED, SAVED, ERROR
-  // Possible subsequent states: SAVED, ERROR
-  SAVING = 'SAVING',
-  /**
-   * Comment saved to the backend.
-   * Must have `id` and `updated` set.
+   * Currently not saving. Not yet saved or last saving attempt successful.
    */
   // Possible prior states: SAVING
   // Possible subsequent states: SAVING
-  SAVED = 'SAVED',
+  OK = 'OK',
+  /**
+   * Currently saving to the backend.
+   */
+  // Possible prior states: OK, ERROR
+  // Possible subsequent states: OK, ERROR
+  SAVING = 'SAVING',
   /**
    * Latest saving attempt failed with an error.
    */
@@ -727,19 +719,25 @@
 export type DraftInfo = Omit<CommentInfo, 'id' | 'updated'> & {
   // Must be set for all drafts.
   // Drafts received from the backend will be modified immediately with
-  // `state: SAVED` before allowing them to get into the model.
-  state: DraftState;
-  // Must be set for UNSAVED drafts. Can be set for other drafts.
+  // `state: OK` before allowing them to get into the model.
+  savingState: SavingState;
+  // Must be set for new drafts created in this session.
   // Use the id() utility function for uniquely identifying drafts.
   client_id?: UrlEncodedCommentId;
-  // Must be set for SAVED drafts. Must not be set for UNSAVED drafts.
+  // Must be set for drafts known to the backend.
   // Use the id() utility function for uniquely identifying drafts.
   id?: UrlEncodedCommentId;
-  // Must be set for SAVED drafts. Can be set for other drafts. Will then just
-  // contain the last value of then the draft was previously SAVED.
+  // Set, iff `id` is set. Reflects the time when the draft was last saved to
+  // the backend.
   updated?: Timestamp;
 };
 
+export interface NewDraftInfo extends DraftInfo {
+  client_id: UrlEncodedCommentId;
+  id: undefined;
+  updated: undefined;
+}
+
 /**
  * This is what human, robot and draft comments can agree upon.
  *
@@ -760,42 +758,30 @@
 export function isDraft<T extends Comment>(
   x: T | DraftInfo | undefined
 ): x is DraftInfo {
-  return (
-    !!x &&
-    (x as DraftInfo).state !== undefined &&
-    (x as DraftInfo).state !== DraftState.UNSAVED
-  );
-}
-
-export function isUnsaved<T extends Comment>(
-  x: T | DraftInfo | undefined
-): x is DraftInfo {
-  return !!x && (x as DraftInfo).state === DraftState.UNSAVED;
-}
-
-export function isSaved<T extends Comment>(
-  x: T | DraftInfo | undefined
-): x is DraftInfo {
-  return !!x && (x as DraftInfo).state === DraftState.SAVED;
+  return !!x && (x as DraftInfo).savingState !== undefined;
 }
 
 export function isSaving<T extends Comment>(
   x: T | DraftInfo | undefined
-): x is DraftInfo {
-  return !!x && (x as DraftInfo).state === DraftState.SAVING;
+): boolean {
+  return !!x && (x as DraftInfo).savingState === SavingState.SAVING;
 }
 
 export function isError<T extends Comment>(
   x: T | DraftInfo | undefined
-): x is DraftInfo {
-  return !!x && (x as DraftInfo).state === DraftState.ERROR;
+): boolean {
+  return !!x && (x as DraftInfo).savingState === SavingState.ERROR;
 }
 
-// TODO: Find a better name for this. Maybe this can become just `isDraft()`.
-export function isDraftOrUnsaved<T extends Comment>(
+/**
+ * A new draft comment is a comment that was created by the user in this session
+ * and has not yet been saved to the backend. Such a comment must have a
+ * `client_id`, but it must not have an `id`.
+ */
+export function isNew<T extends Comment>(
   x: T | DraftInfo | undefined
-): x is DraftInfo {
-  return isDraft(x) || isUnsaved(x);
+): boolean {
+  return !!x && !!(x as DraftInfo).client_id && !(x as DraftInfo).id;
 }
 
 export interface CommentThread {
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index cee7e2c..802d15d 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -23,11 +23,11 @@
   ChangeMessage,
   isRobot,
   isDraft,
-  isDraftOrUnsaved,
   Comment,
-  isUnsaved,
   CommentIdToCommentThreadMap,
-  DraftState,
+  SavingState,
+  NewDraftInfo,
+  isNew,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -63,12 +63,14 @@
  * e.g. in `repeat()` directives.
  */
 export function id(comment: Comment): UrlEncodedCommentId {
-  if (isUnsaved(comment)) {
-    assertIsDefined(comment.client_id);
-    return comment.client_id;
-  }
-  if (isDraft(comment) && comment.client_id) {
-    return comment.client_id;
+  if (isDraft(comment)) {
+    if (isNew(comment)) {
+      assertIsDefined(comment.client_id);
+      return comment.client_id;
+    }
+    if (comment.client_id) {
+      return comment.client_id;
+    }
   }
   assertIsDefined(comment.id);
   return comment.id;
@@ -76,9 +78,9 @@
 
 export function sortComments<T extends Comment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
-    const u1 = isUnsaved(c1);
-    const u2 = isUnsaved(c2);
-    if (u1 !== u2) return u1 ? 1 : -1;
+    const n1 = isNew(c1);
+    const n2 = isNew(c2);
+    if (n1 !== n2) return n1 ? 1 : -1;
 
     const d1 = isDraft(c1);
     const d2 = isDraft(c2);
@@ -97,34 +99,40 @@
   });
 }
 
-export function createUnsaved(message?: string, unresolved?: boolean) {
-  return {
-    message,
-    unresolved,
-    state: DraftState.UNSAVED,
+export function createNew(
+  message?: string,
+  unresolved?: boolean
+): NewDraftInfo {
+  const newDraft: NewDraftInfo = {
+    savingState: SavingState.OK,
     client_id: uuid() as UrlEncodedCommentId,
+    id: undefined,
+    updated: undefined,
   };
+  if (message !== undefined) newDraft.message = message;
+  if (unresolved !== undefined) newDraft.unresolved = unresolved;
+  return newDraft;
 }
 
-export function createUnsavedPatchsetLevel(
+export function createNewPatchsetLevel(
   patchNum?: PatchSetNumber,
   message?: string,
   unresolved?: boolean
 ): DraftInfo {
   return {
-    ...createUnsaved(message, unresolved),
+    ...createNew(message, unresolved),
     patch_set: patchNum,
     path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
   };
 }
 
-export function createUnsavedReply(
+export function createNewReply(
   replyingTo: CommentInfo,
   message: string,
   unresolved: boolean
 ): DraftInfo {
   return {
-    ...createUnsaved(message, unresolved),
+    ...createNew(message, unresolved),
     path: replyingTo.path,
     patch_set: replyingTo.patch_set,
     side: replyingTo.side,
@@ -198,7 +206,7 @@
 export function getLastPublishedComment(
   thread: CommentThread
 ): CommentInfo | DraftInfo | undefined {
-  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+  const publishedComments = thread.comments.filter(c => !isDraft(c));
   const len = publishedComments.length;
   return publishedComments[len - 1];
 }
@@ -428,7 +436,7 @@
   const updated: {[path: string]: DraftInfo[]} = {};
   for (const filePath of Object.keys(draftsByPath)) {
     updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
-      return {...draft, state: DraftState.SAVED};
+      return {...draft, savingState: SavingState.OK};
     });
   }
   return updated;
@@ -442,7 +450,7 @@
     unresolved: comment?.unresolved,
     path_length: comment?.path?.length,
     line: comment?.range?.start_line ?? comment?.line,
-    unsaved: isUnsaved(comment),
+    unsaved: isNew(comment),
   };
 }
 
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 49d387e..ab21c67 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -26,7 +26,7 @@
 import {
   Comment,
   DraftInfo,
-  DraftState,
+  SavingState,
   PARENT,
   RevisionPatchSetNum,
   Timestamp,
@@ -134,7 +134,7 @@
       {
         id: 'new_draft' as UrlEncodedCommentId,
         message: 'i do not like either of you',
-        state: DraftState.SAVED,
+        savingState: SavingState.OK,
         updated: '2015-12-20 15:01:20.396000000' as Timestamp,
       } as DraftInfo,
       {
@@ -159,7 +159,7 @@
 
   suite('createCommentThreads', () => {
     test('creates threads from individual comments', () => {
-      const comments = [
+      const comments: Comment[] = [
         {
           id: 'sallys_confession' as UrlEncodedCommentId,
           message: 'i like you, jack',
@@ -180,7 +180,7 @@
         {
           id: 'new_draft' as UrlEncodedCommentId,
           message: 'i do not like either of you' as UrlEncodedCommentId,
-          state: DraftState.SAVED,
+          savingState: SavingState.OK,
           updated: '2015-12-20 15:01:20.396000000' as Timestamp,
           patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',