Merge "Generate a warning when new rules.pl files are uploaded"
diff --git a/.bazelversion b/.bazelversion
index c7cb131..5e32542 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-5.3.1
+6.1.2
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 55abe47..4c666b9 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2107,6 +2107,16 @@
 +
 Default is 1 hour.
 
+[[core.usePerRequestRefCache]]core.usePerRequestRefCache::
++
+Use a per request (currently per request thread) ref cache. The ref
+cache uses JGit's SnapshottingRefDirectory to ensure that packed
+refs are checked and potentially read at least once per request
+(lazily) if needed. This helps reduce the overhead of checking if
+the packed-refs file is outdated.
++
+Default is true.
+
 [[dashboard]]
 === Section dashboard
 
@@ -3988,6 +3998,9 @@
 All users must be a member of this group to allow account creation or
 authentication.
 +
+For example, setting to `ldap/gerritaccess` limits account creation or
+authentication to members of the ldap group `gerritaccess`.
++
 Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
 +
 By default, unset.
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index d1a5bcf..514a4c9 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -352,7 +352,7 @@
 ----
 
 Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
-configuration in Eclipe and specify the port `5005`.
+configuration in Eclipse and specify the port `5005`.
 
 [[logging]]
 === Controlling logging level
@@ -551,7 +551,7 @@
 ----
 
 Update the `polygerrit-ui/app/node_modules_licenses/licenses.ts` file. You should add licenses
-for the package itself and for all transitive depndencies. If you forgot to add a license, the
+for the package itself and for all transitive dependencies. If you forgot to add a license, the
 `Documentation:check_licenses` test will fail.
 
 After the update, commit all changes to the repository (including `yarn.lock`).
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index ac0780d..6150c20 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -28,7 +28,7 @@
   might take a while until you could benefit from it. In that case,
   implement the feature on master and, if you really need it on an
   earlier `stable-*` branch, cherry-pick the change and build
-  Gerrit on your own environent.
+  Gerrit in your own environment.
 * Bug-fixes should generally at least cover the oldest affected and
   still supported version. If you're affected and run an even older
   version, you're welcome to upload to that older version, even if
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 5636dfd..176b53f 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -98,7 +98,7 @@
 User authentication is handled by identity realms. Gerrit supports the
 following types of authentication:
 
-* OpenId (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
+* OpenID (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
 * OAuth2
 * LDAP
 * Google accounts (on googlesource.com)
@@ -373,7 +373,7 @@
 and one for SSH).
 
 The git wire protocol does a client/server negotiation to avoid
-sending too much data. This negotation occupies a CPU, so the number
+sending too much data. This negotiation occupies a CPU, so the number
 of concurrent push/fetch operations should be capped by the number of
 CPUs.
 
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 79febe4..7353092 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -61,9 +61,10 @@
 startup --output_user_root=/Users/johndoe/.cache/bazel
 ----
 
-==== Increase the treshold for the cleanup of temporary files
-The default treshold for the cleanup can be overriden by creating a configuration file under
-`/etc/periodic.conf` and setting a larger value for the `daily_clean_tmps_days`.
+==== Increase the threshold for the cleanup of temporary files
+The default threshold for the cleanup can be overridden by creating a configuration
+file under `/etc/periodic.conf` and setting a larger value for the
+`daily_clean_tmps_days`.
 
 An example `/etc/periodic.conf` file:
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index 149b14a..49fdc8f 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -31,13 +31,13 @@
 === Installation of IntelliJ IDEA
 
 Please refer to the
-link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by Jetbrains,role=external,window=_blank]
+link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by JetBrains,role=external,window=_blank]
 to install it on your platform. Make sure to install a version compatible with
 the Bazel plugin as mentioned above.
 
 == Installation of the Bazel plugin
 
-The plugin is usually installed using the Jetbrains plugin repository as shown
+The plugin is usually installed using the JetBrains plugin repository as shown
 in the steps below, but it's also possible to
 link:https://github.com/bazelbuild/intellij[build it from source].
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 7918920..7961d81 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -963,7 +963,7 @@
 When calling command options not provided by your plugin, there is always
 a risk that the options may not exist, perhaps because the options being
 called are to be provided by another plugin, and said plugin is not
-currently installed. To protect againt this situation, it is possible to
+currently installed. To protect against this situation, it is possible to
 define an option as being dependent on other options using the
 @RequiresOptions() annotation. If the required options are not all not
 currently present, then the dependent option will not be available or
@@ -999,7 +999,7 @@
 @RequiresOptions("--format")
 @Option(
   name = "--special",
-  usage = "ouptut results using json",
+  usage = "output results using json",
   handler = JsonOutputOptionHandler.class
 )
 boolean json;
@@ -2725,7 +2725,7 @@
 Plugins are expected to support rules inheritance themselves, providing ways
 to configure it and handling the logic behind it.
 Please note that no inheritance is sometimes better than badly handled
-inheritance: mis-communication and strange behaviors caused by inheritance
+inheritance: miscommunication and strange behaviors caused by inheritance
 may and will confuse the users. Each plugins is responsible for handling the
 project hierarchy and taking wise actions. Gerrit does not enforce it.
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 6ff064c..70f41af 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,7 +178,7 @@
 NOTE: To learn why using `java -jar` isn't sufficient, see
 <<special_bazel_java_version,this explanation>>.
 
-NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+NOTE: When launching the daemon this way, the settings from the `[container]` section from the
 `$GERRIT_SITE/etc/gerrit.config` are not honored.
 
 To debug the Gerrit server of this test site:
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 974e897..6c9dfef 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -131,6 +131,7 @@
 * `proc/cpu/usage`: CPU time used by the Gerrit process.
 * `proc/cpu/system_load`: System load average for the last minute.
 * `proc/num_open_fds`: Number of open file descriptors.
+* `proc/jvm/memory/allocated`: Total memory allocated by all threads since Gerrit process started.
 * `proc/jvm/memory/heap_committed`: Amount of memory guaranteed for user objects.
 * `proc/jvm/memory/heap_used`: Amount of memory holding user objects.
 * `proc/jvm/memory/non_heap_committed`: Amount of memory guaranteed for classes,
diff --git a/Documentation/pgm-init.txt b/Documentation/pgm-init.txt
index 3c9e3fc..4b346fe 100644
--- a/Documentation/pgm-init.txt
+++ b/Documentation/pgm-init.txt
@@ -38,11 +38,6 @@
 	install, reasonable configuration defaults are chosen based
 	on the whims of the Gerrit developers. On upgrades, the existing
 	settings in `gerrit.config` are respected.
-+
-If during a schema migration unused objects (e.g. tables, columns)
-are detected, they are *not* automatically dropped; a list of SQL
-statements to drop these objects is provided. To drop the unused
-objects these SQL statements must be executed manually.
 
 --delete-caches::
 	Force deletion of all persistent cache files. Note that
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index bf51252..0a913d8 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -379,6 +379,18 @@
   as link:#tracking-id-info[TrackingIdInfo].
 --
 
+[[custom-keyed-values]]
+--
+* `CUSTOM_KEYED_VALUES`: include the custom key-value map
+--
+
+[[star]]
+--
+* `STAR`: include the `starred` field in
+   link:#change-info[ChangeInfo], which indicates if the change is starred
+   by the current user or not.
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -6919,6 +6931,11 @@
 |==================================
 |Field Name           ||Description
 |`id`                 ||
+The ID of the change. Subject to a 'GerritBackendFeature__return_new_change_info_id'
+ experiment, the format is either "'<project>\~<_number>'" (new format),
+or `triplet_id`. The experiment is needed to allow callers to migrate.
+'project' and '_number' are URL encoded. The callers must not rely on the format.
+|`triplet_id`         ||
 The ID of the change in the format "'<project>\~<branch>~<Change-Id>'",
 where 'project', 'branch' and 'Change-Id' are URL encoded. For 'branch' the
 `refs/heads/` prefix is omitted.
@@ -6939,6 +6956,10 @@
  of the account from the attention set.
 |`hashtags`           |optional|
 List of hashtags that are set on the change.
+|`custom_keyed_values`       |optional|
+A map that maps custom keys to custom values that are tied to a specific change,
+both in the form of strings. Only set if link:#custom-keyed-values[custom keyed
+values] are requested.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6958,6 +6979,7 @@
 link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
 Whether the calling user has starred this change with the default label.
+Only set if link:#star[requested].
 |`stars`              |optional|
 A list of star labels that are applied by the calling user to this
 change. The labels are lexicographically sorted.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 4c12f9f..fe9b13c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1577,6 +1577,8 @@
 bit is indexed.
 |`enable_robot_comments`|not set if `false`|
 link:config-gerrit.html#change.enableRobotComments[Are robot comments enabled?].
+|`conflicts_predicate_enabled`|not set if `false`|
+link:config-gerrit.html#change.conflictsPredicateEnabled[Are conflicts enabled?].
 |=============================
 
 [[change-index-config-info]]
@@ -2087,10 +2089,11 @@
 |`start_time` ||The start time of the task.
 |`delay`      ||The remaining delay of the task.
 |`command`    ||The command of the task.
+|`queue_name` ||The work queue the task is associated with.
 |`remote_name`|optional|
 The remote name. May only be set for tasks that are associated with a
 project.
-|`project`    |optional|The project the task is associated with.
+|`project_name`    |optional|The project the task is associated with.
 |====================================
 
 [[task-summary-info]]
diff --git a/WORKSPACE b/WORKSPACE
index 047da6a..29266cd 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -40,6 +40,53 @@
     ],
 )
 
+# TODO: Remove this when java_tools v12.1 included in regular Bazel release
+# See https://github.com/bazelbuild/bazel/issues/17695
+http_archive(
+    name = "remote_java_tools",
+    sha256 = "0db35ec44745fd15b77d9df954e70a4fcf74554dd5bfe3f6e6cb6bbdc1f1c649",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_linux",
+    sha256 = "093ecac3b42fcbc3621d08edc3ae3c8b0bc2bf56a0d9a85ddcdb1e0bcf10cbc7",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_linux-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_linux-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_windows",
+    sha256 = "1df7cc7fac54f437f43c24c019462e13058f394fdba5a64f566b92e8af18d0cf",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_windows-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_windows-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_darwin_x86_64",
+    sha256 = "16ca145203a62a1fcd6ae50513c0935d938591cb309b9b1172e257c57873f60d",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_darwin_x86_64-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_darwin_x86_64-v12.1.zip",
+    ],
+)
+
+http_archive(
+    name = "remote_java_tools_darwin_arm64",
+    sha256 = "1d8e575e558782c2ceec0940e424f0e2df56b0df3d7fae68333eaceef2c4e41c",
+    urls = [
+        "https://mirror.bazel.build/bazel_java_tools/releases/java/v12.1/java_tools_darwin_arm64-v12.1.zip",
+        "https://github.com/bazelbuild/java_tools/releases/download/java_v12.1/java_tools_darwin_arm64-v12.1.zip",
+    ],
+)
+
 http_archive(
     name = "rbe_jdk11",
     sha256 = "dbcfd6f26589ef506b91fe03a12dc559ca9c84699e4cf6381150522287f0e6f6",
diff --git a/java/Main.java b/java/Main.java
index 09c8c76..c04db2c 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -16,6 +16,7 @@
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
+  private static final Runtime.Version MIN_JAVA_VERSION = Runtime.Version.parse("11.0.10");
 
   // We don't do any real work here because we need to import
   // the archive lookup code and we cannot import a class in
@@ -34,11 +35,11 @@
   }
 
   private static boolean onSupportedJavaVersion() {
-    final String version = System.getProperty("java.specification.version");
-    if (1.8 <= parse(version)) {
+    Runtime.Version version = Runtime.version();
+    if (version.compareTo(MIN_JAVA_VERSION) >= 0) {
       return true;
     }
-    System.err.println("fatal: Gerrit Code Review requires Java 8 or later");
+    System.err.println("fatal: Gerrit Code Review requires Java " + MIN_JAVA_VERSION + " or later");
     System.err.println("       (trying to run on Java " + version + ")");
     return false;
   }
@@ -58,22 +59,5 @@
         "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
   }
 
-  private static double parse(String version) {
-    if (version == null || version.length() == 0) {
-      return 0.0;
-    }
-
-    try {
-      final int fd = version.indexOf('.');
-      final int sd = version.indexOf('.', fd + 1);
-      if (0 < sd) {
-        version = version.substring(0, sd);
-      }
-      return Double.parseDouble(version);
-    } catch (NumberFormatException e) {
-      return 0.0;
-    }
-  }
-
   private Main() {}
 }
diff --git a/java/com/google/gerrit/auth/BUILD b/java/com/google/gerrit/auth/BUILD
index e844696..f04334d 100644
--- a/java/com/google/gerrit/auth/BUILD
+++ b/java/com/google/gerrit/auth/BUILD
@@ -12,8 +12,6 @@
     srcs = glob(
         ["**/*.java"],
     ),
-    resource_strip_prefix = "resources",
-    resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index dbbcf71..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,7 +10,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server:project_util",
         "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/entities/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
index 605c40c..09f63d4 100644
--- a/java/com/google/gerrit/entities/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.entities;
 
+import com.google.gerrit.common.Nullable;
+
 /**
  * Contains all inheritable boolean project configs and maps internal representations to API
  * objects.
@@ -58,6 +60,7 @@
     return section;
   }
 
+  @Nullable
   public String getSubSection() {
     return null;
   }
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index b43650b..72ca6a9 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.server.ProjectUtil;
 import java.io.Serializable;
 import java.util.Arrays;
 import java.util.HashMap;
diff --git a/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/entities/ProjectUtil.java
similarity index 97%
rename from java/com/google/gerrit/server/ProjectUtil.java
rename to java/com/google/gerrit/entities/ProjectUtil.java
index e87c8bf..98ce67a 100644
--- a/java/com/google/gerrit/server/ProjectUtil.java
+++ b/java/com/google/gerrit/entities/ProjectUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.entities;
 
 public class ProjectUtil {
   public static String sanitizeProjectName(String name) {
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index f1f7831..e2a7c1e 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -88,7 +88,13 @@
   SKIP_DIFFSTAT(23),
 
   /** Include the evaluated submit requirements for the caller. */
-  SUBMIT_REQUIREMENTS(24);
+  SUBMIT_REQUIREMENTS(24),
+
+  /** Include custom keyed values. */
+  CUSTOM_KEYED_VALUES(25),
+
+  /** Include the 'starred' field, that is if the change is starred by the current user . */
+  STAR(26);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 2295922..80bf130 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -22,4 +22,5 @@
   public Boolean submitWholeTopic;
   public String mergeabilityComputationBehavior;
   public Boolean enableRobotComments;
+  public Boolean conflictsPredicateEnabled;
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index dc9bc32..a2e2e8f 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -37,6 +37,8 @@
   // protected by any ListChangesOption.
 
   public String id;
+  public String tripletId;
+
   public String project;
   public String branch;
   public String topic;
@@ -49,6 +51,8 @@
 
   public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
 
+  public Map<String, String> customKeyedValues;
+
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
diff --git a/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
index 071dac1..7e0b623 100644
--- a/java/com/google/gerrit/extensions/events/CommentAddedListener.java
+++ b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.events;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import java.util.Map;
@@ -22,6 +23,7 @@
 @ExtensionPoint
 public interface CommentAddedListener {
   interface Event extends RevisionEvent {
+    @Nullable
     String getComment();
 
     Map<String, ApprovalInfo> getApprovals();
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index b2173c4..fcf4f0f 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -11,7 +11,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-factory",
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
new file mode 100644
index 0000000..2c1cae6
--- /dev/null
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2023 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.
+
+package com.google.gerrit.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.util.NB;
+
+public class PublicKeyStoreUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExternalIds externalIds;
+  private final Provider<PublicKeyStore> storeProvider;
+
+  @Inject
+  public PublicKeyStoreUtil(ExternalIds externalIds, Provider<PublicKeyStore> storeProvider) {
+    this.externalIds = externalIds;
+    this.storeProvider = storeProvider;
+  }
+
+  public static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
+    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
+  }
+
+  public static long keyIdFromFingerprint(byte[] fp) {
+    return NB.decodeInt64(fp, fp.length - 8);
+  }
+
+  public List<PGPPublicKey> listGpgKeysForUser(Account.Id id) throws PGPException, IOException {
+    List<PGPPublicKey> keys = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (ExternalId extId : getGpgExtIds(id)) {
+        byte[] fp = parseFingerprint(extId);
+        boolean found = false;
+        for (PGPPublicKeyRing keyRing : store.get(keyIdFromFingerprint(fp))) {
+          if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
+            found = true;
+            keys.add(keyRing.getPublicKey());
+            break;
+          }
+        }
+        if (!found) {
+          logger.atWarning().log(
+              "No public key stored for fingerprint %s", Fingerprint.toString(fp));
+        }
+      }
+    }
+    return keys;
+  }
+
+  public Iterable<ExternalId> getGpgExtIds(Account.Id id) throws IOException {
+    return externalIds.byAccount(id, SCHEME_GPGKEY);
+  }
+
+  public RefUpdate.Result deletePgpKey(PGPPublicKey key, PersonIdent committer, PersonIdent author)
+      throws PGPException, IOException {
+    return deletePgpKeys(ImmutableList.of(key), committer, author).get(0);
+  }
+
+  public List<RefUpdate.Result> deletePgpKeys(
+      List<PGPPublicKey> keys, PersonIdent committer, PersonIdent author)
+      throws IOException, PGPException {
+    List<RefUpdate.Result> res = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (PGPPublicKey key : keys) {
+        store.remove(key.getFingerprint());
+
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(author);
+        cb.setCommitter(committer);
+        cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+        RefUpdate.Result saveResult = store.save(cb);
+        res.add(saveResult);
+      }
+    }
+    return res;
+  }
+}
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 6ae0334..57fda5b 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -70,8 +68,10 @@
       return gpgKeys.get().list().apply(account).value();
     } catch (PGPException | IOException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot list GPG keys", e);
+      throw RestApiException.wrap("Cannot list GPG keys", e);
     }
   }
 
@@ -86,8 +86,10 @@
       return postGpgKeys.get().apply(account, in).value();
     } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot put GPG keys", e);
+      throw RestApiException.wrap("Cannot put GPG keys", e);
     }
   }
 
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 0ff12e8..2a05f35 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -50,7 +48,7 @@
     try {
       return get.apply(rsrc).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get GPG key", e);
+      throw RestApiException.wrap("Cannot get GPG key", e);
     }
   }
 
@@ -58,8 +56,10 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new Input());
+    } catch (RestApiException e) {
+      throw e;
     } catch (PGPException | IOException | ConfigInvalidException e) {
-      throw asRestApiException("Cannot delete GPG key", e);
+      throw RestApiException.wrap("Cannot delete GPG key", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index bcc8631..6381ada 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.collect.ImmutableList;
@@ -28,13 +27,14 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -42,7 +42,6 @@
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 
@@ -50,25 +49,25 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<PublicKeyStore> storeProvider;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<PublicKeyStore> storeProvider,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       ExternalIds externalIds,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      DeleteKeyEmailFactories deleteKeyEmailFactories,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
-    this.storeProvider = storeProvider;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
@@ -90,42 +89,35 @@
             rsrc.getUser().getAccountId(),
             u -> u.deleteExternalId(extId.get()));
 
-    try (PublicKeyStore store = storeProvider.get()) {
-      store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author = rsrc.getUser().newCommitterIdent(committer);
 
-      CommitBuilder cb = new CommitBuilder();
-      PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
-      cb.setCommitter(committer);
-      cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
-
-      RefUpdate.Result saveResult = store.save(cb);
-      switch (saveResult) {
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          try {
-            deleteKeySenderFactory
-                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
-                .send();
-          } catch (EmailException e) {
-            logger.atSevere().withCause(e).log(
-                "Cannot send GPG key deletion message to %s",
-                rsrc.getUser().getAccount().preferredEmail());
-          }
-          break;
-        case LOCK_FAILURE:
-        case FORCED:
-        case IO_FAILURE:
-        case NEW:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
-      }
+    RefUpdate.Result saveResult = publicKeyStoreUtil.deletePgpKey(key, committer, author);
+    switch (saveResult) {
+      case NO_CHANGE:
+      case FAST_FORWARD:
+        try {
+          deleteKeyEmailFactories
+              .createEmail(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
+              .send();
+        } catch (EmailException e) {
+          logger.atSevere().withCause(e).log(
+              "Cannot send GPG key deletion message to %s",
+              rsrc.getUser().getAccount().preferredEmail());
+        }
+        break;
+      case LOCK_FAILURE:
+      case FORCED:
+      case IO_FAILURE:
+      case NEW:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index 00a0f57..9fb8286 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,10 +33,10 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,36 +45,35 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.util.NB;
 
 @Singleton
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
       DynamicMap<RestView<GpgKey>> views,
       Provider<CurrentUser> self,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory,
-      ExternalIds externalIds) {
+      GerritPublicKeyChecker.Factory checkerFactory) {
     this.views = views;
     this.self = self;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.externalIds = externalIds;
   }
 
   @Override
@@ -90,10 +86,11 @@
       throws ResourceNotFoundException, PGPException, IOException {
     checkVisible(self, parent);
 
-    ExternalId gpgKeyExtId = findGpgKey(id.get(), getGpgExtIds(parent));
-    byte[] fp = parseFingerprint(gpgKeyExtId);
+    ExternalId gpgKeyExtId =
+        findGpgKey(id.get(), publicKeyStoreUtil.getGpgExtIds(parent.getUser().getAccountId()));
+    byte[] fp = PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId);
     try (PublicKeyStore store = storeProvider.get()) {
-      long keyId = keyId(fp);
+      long keyId = PublicKeyStoreUtil.keyIdFromFingerprint(fp);
       for (PGPPublicKeyRing keyRing : store.get(keyId)) {
         PGPPublicKey key = keyRing.getPublicKey();
         if (Arrays.equals(key.getFingerprint(), fp)) {
@@ -131,10 +128,6 @@
     return gpgKeyExtId;
   }
 
-  static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
-    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
-  }
-
   @Override
   public DynamicMap<RestView<GpgKey>> views() {
     return views;
@@ -145,29 +138,17 @@
     public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc)
         throws PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
-      Map<String, GpgKeyInfo> keys = new HashMap<>();
+      List<PGPPublicKey> keys =
+          publicKeyStoreUtil.listGpgKeysForUser(rsrc.getUser().getAccountId());
+      Map<String, GpgKeyInfo> res = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
-        for (ExternalId extId : getGpgExtIds(rsrc)) {
-          byte[] fp = parseFingerprint(extId);
-          boolean found = false;
-          for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
-            if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
-              found = true;
-              GpgKeyInfo info =
-                  toJson(
-                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
-              keys.put(info.id, info);
-              info.id = null;
-              break;
-            }
-          }
-          if (!found) {
-            logger.atWarning().log(
-                "No public key stored for fingerprint %s", Fingerprint.toString(fp));
-          }
+        for (PGPPublicKey key : keys) {
+          GpgKeyInfo info = toJson(key, checkerFactory.create(rsrc.getUser(), store), store);
+          res.put(info.id, info);
+          info.id = null;
         }
       }
-      return Response.ok(keys);
+      return Response.ok(res);
     }
   }
 
@@ -194,14 +175,6 @@
     }
   }
 
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
-    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
-  }
-
-  private static long keyId(byte[] fp) {
-    return NB.decodeInt64(fp, fp.length - 8);
-  }
-
   static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
       throws ResourceNotFoundException {
     if (!BouncyCastleUtil.havePGP()) {
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d51ee6a..edd5a58 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,8 +58,8 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.AddKeyEmailFactories;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
@@ -90,8 +91,8 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeySenderFactory;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final AddKeyEmailFactories addKeyEmailFactories;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
@@ -105,8 +106,8 @@
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeySenderFactory,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      AddKeyEmailFactories addKeyEmailFactories,
+      DeleteKeyEmailFactories deleteKeyEmailFactories,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
@@ -117,8 +118,8 @@
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeySenderFactory = addKeySenderFactory;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.addKeyEmailFactories = addKeyEmailFactories;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -175,7 +176,8 @@
     for (String id : input.delete) {
       try {
         ExternalId gpgKeyExtId = GpgKeys.findGpgKey(id, existingExtIds);
-        fingerprints.put(gpgKeyExtId, new Fingerprint(GpgKeys.parseFingerprint(gpgKeyExtId)));
+        fingerprints.put(
+            gpgKeyExtId, new Fingerprint(PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId)));
       } catch (ResourceNotFoundException e) {
         // Skip removal.
       }
@@ -261,7 +263,7 @@
         case FORCED:
           if (!addedKeys.isEmpty()) {
             try {
-              addKeySenderFactory.create(user, addedKeys).send();
+              addKeyEmailFactories.createEmail(user, addedKeys).send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
                   "Cannot send GPG key added message to %s",
@@ -270,8 +272,8 @@
           }
           if (!toRemove.isEmpty()) {
             try {
-              deleteKeySenderFactory
-                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+              deleteKeyEmailFactories
+                  .createEmail(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
                   .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 210ba7b..9056732 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -1615,7 +1615,9 @@
   private void checkUserSession(HttpServletRequest req) throws AuthException {
     CurrentUser user = globals.currentUser.get();
     if (isRead(req)) {
-      user.setAccessPath(AccessPath.REST_API);
+      if (user.getAccessPath().equals(AccessPath.UNKNOWN)) {
+        user.setAccessPath(AccessPath.REST_API);
+      }
     } else if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
     } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
diff --git a/java/com/google/gerrit/index/project/IndexedProjectQuery.java b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
index 5fc74ca..52d08d6 100644
--- a/java/com/google/gerrit/index/project/IndexedProjectQuery.java
+++ b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.index.project;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.IndexedQuery;
+import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 
@@ -27,11 +30,27 @@
  * com.google.gerrit.index.IndexRewriter}. See {@link IndexedQuery}.
  */
 public class IndexedProjectQuery extends IndexedQuery<Project.NameKey, ProjectData>
-    implements DataSource<ProjectData> {
+    implements DataSource<ProjectData>, Matchable<ProjectData> {
 
   public IndexedProjectQuery(
       Index<Project.NameKey, ProjectData> index, Predicate<ProjectData> pred, QueryOptions opts)
       throws QueryParseException {
     super(index, pred, opts.convertForBackend());
   }
+
+  @Override
+  public boolean match(ProjectData object) {
+    Predicate<ProjectData> pred = getChild(0);
+    checkState(
+        pred.isMatchable(),
+        "match invoked, but child predicate %s doesn't implement %s",
+        pred,
+        Matchable.class.getName());
+    return pred.asMatchable().match(object);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3ac594e..dac1012 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -62,7 +62,10 @@
   @Deprecated static final Schema<ProjectData> V4 = schema(V3);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<ProjectData> V5 = schema(V4);
+  @Deprecated static final Schema<ProjectData> V5 = schema(V4);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<ProjectData> V6 = schema(V5);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index f5f30bd..3adf881 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.IndexConfig;
 import java.util.Collection;
 import java.util.List;
@@ -24,36 +23,17 @@
 public class AndSource<T> extends AndPredicate<T> implements DataSource<T> {
   protected final DataSource<T> source;
 
-  private final IsVisibleToPredicate<T> isVisibleToPredicate;
   private final int start;
   private final int cardinality;
   private final IndexConfig indexConfig;
 
   public AndSource(Collection<? extends Predicate<T>> that, IndexConfig indexConfig) {
-    this(that, null, 0, indexConfig);
+    this(that, 0, indexConfig);
   }
 
-  public AndSource(
-      Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, IndexConfig indexConfig) {
-    this(that, isVisibleToPredicate, 0, indexConfig);
-  }
-
-  public AndSource(
-      Predicate<T> that,
-      IsVisibleToPredicate<T> isVisibleToPredicate,
-      int start,
-      IndexConfig indexConfig) {
-    this(ImmutableList.of(that), isVisibleToPredicate, start, indexConfig);
-  }
-
-  public AndSource(
-      Collection<? extends Predicate<T>> that,
-      IsVisibleToPredicate<T> isVisibleToPredicate,
-      int start,
-      IndexConfig indexConfig) {
+  public AndSource(Collection<? extends Predicate<T>> that, int start, IndexConfig indexConfig) {
     super(that);
     checkArgument(start >= 0, "negative start: %s", start);
-    this.isVisibleToPredicate = isVisibleToPredicate;
     this.start = start;
     this.indexConfig = indexConfig;
 
@@ -93,16 +73,7 @@
   }
 
   @Override
-  public boolean isMatchable() {
-    return isVisibleToPredicate != null || super.isMatchable();
-  }
-
-  @Override
   public boolean match(T object) {
-    if (isVisibleToPredicate != null && !isVisibleToPredicate.match(object)) {
-      return false;
-    }
-
     if (super.isMatchable() && !super.match(object)) {
       return false;
     }
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 337332f..8431ccc6 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -87,6 +87,9 @@
                   r.add(data);
                 }
                 pageResultSize++;
+                if (r.size() > limit) {
+                  break;
+                }
               }
               nextStart += pageResultSize;
               searchAfter = next.searchAfter();
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index f9dc31a..78ee128 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -544,8 +544,7 @@
         int realLimit = opts.start() + opts.pageSize();
         TopFieldDocs docs =
             opts.searchAfter() != null
-                ? searcher.searchAfter(
-                    (ScoreDoc) opts.searchAfter(), query, realLimit, sort, false, false)
+                ? searcher.searchAfter((ScoreDoc) opts.searchAfter(), query, realLimit, sort, false)
                 : searcher.search(query, realLimit, sort);
         ImmutableList.Builder<T> b = ImmutableList.builderWithExpectedSize(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 6365260..728606d 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -415,12 +415,7 @@
             if (maxRemainingHits > 0) {
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
-                      searchAfter,
-                      query,
-                      maxRemainingHits,
-                      sort,
-                      /* doDocScores= */ false,
-                      /* doMaxScore= */ false);
+                      searchAfter, query, maxRemainingHits, sort, /* doDocScores= */ false);
               searchAfterHitsCount += subIndexHits.scoreDocs.length;
               hits.add(subIndexHits);
               searchAfterBySubIndex.put(
diff --git a/java/com/google/gerrit/metrics/dropwizard/BUILD b/java/com/google/gerrit/metrics/dropwizard/BUILD
index 4b3859f..dbb8f5e 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/java/com/google/gerrit/metrics/dropwizard/BUILD
@@ -12,6 +12,7 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib/dropwizard:dropwizard-core",
+        "//lib/flogger:api",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
index b3860f7..da9ec70 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
+++ b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -110,7 +110,7 @@
     }
   }
 
-  private String submetric(Object key) {
+  String submetric(Object key) {
     return DropWizardMetricMaker.name(ordering, name, name(key));
   }
 
diff --git a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
index d718035..bd3caf9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl1.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import com.codahale.metrics.MetricRegistry;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.metrics.CallbackMetric1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 
 /** Optimized version of {@link BucketedCallback} for single dimension. */
 class CallbackMetricImpl1<F1, V> extends BucketedCallback<V> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   CallbackMetricImpl1(
       DropWizardMetricMaker metrics,
       MetricRegistry registry,
@@ -44,9 +48,14 @@
 
     @Override
     public void set(F1 field1, V value) {
-      BucketedCallback<V>.ValueGauge cell = getOrCreate(field1);
-      cell.value = value;
-      cell.set = true;
+      try {
+        BucketedCallback<V>.ValueGauge cell = getOrCreate(field1);
+        cell.value = value;
+        cell.set = true;
+      } catch (IllegalArgumentException e) {
+        logger.atWarning().withCause(e).atMostEvery(1, TimeUnit.HOURS).log(
+            "Unable to register duplicate metric: %s", submetric(field1));
+      }
     }
 
     @Override
diff --git a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 09e40c1..301ec85 100644
--- a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -34,6 +34,8 @@
 import java.util.concurrent.TimeUnit;
 
 public class ProcMetricModule extends MetricModule {
+  private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
+
   @Override
   protected void configure(MetricMaker metrics) {
     buildLabel(metrics);
@@ -167,6 +169,14 @@
 
           objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
         });
+
+    if (threadMxBean.supportsAllocatedBytes()) {
+      metrics.newCallbackMetric(
+          "proc/jvm/memory/allocated",
+          Long.class,
+          new Description("Allocated memory").setCumulative().setUnit(Units.BYTES),
+          () -> getTotalAllocatedBytes());
+    }
   }
 
   private void procJvmMemoryPool(MetricMaker metrics) {
@@ -312,4 +322,19 @@
           });
     }
   }
+
+  private static long getTotalAllocatedBytes() {
+    if (!threadMxBean.supportsAllocatedBytes()) {
+      return -1;
+    }
+
+    long[] ids = threadMxBean.getAllThreadIds();
+    long[] allocatedBytes = threadMxBean.getAllThreadsAllocatedBytes(ids);
+    long total = 0;
+
+    for (long a : allocatedBytes) {
+      total += a;
+    }
+    return total;
+  }
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
index 546924f..03e59a0 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
@@ -19,4 +19,12 @@
   long getCurrentThreadUserTime();
 
   long getCurrentThreadAllocatedBytes();
+
+  boolean supportsAllocatedBytes();
+
+  long getThreadAllocatedBytes(long threadId);
+
+  long[] getAllThreadsAllocatedBytes(long[] threadIds);
+
+  long[] getAllThreadIds();
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
index 29dd42a..361aba0 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
@@ -37,4 +37,24 @@
   public long getCurrentThreadAllocatedBytes() {
     return -1;
   }
+
+  @Override
+  public boolean supportsAllocatedBytes() {
+    return false;
+  }
+
+  @Override
+  public long getThreadAllocatedBytes(long threadId) {
+    return -1;
+  }
+
+  @Override
+  public long[] getAllThreadsAllocatedBytes(long[] threadIds) {
+    return new long[0];
+  }
+
+  @Override
+  public long[] getAllThreadIds() {
+    return sys.getAllThreadIds();
+  }
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index f5555b5..ca750cd 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -37,4 +37,24 @@
   public long getCurrentThreadAllocatedBytes() {
     return sys.getCurrentThreadAllocatedBytes();
   }
+
+  @Override
+  public boolean supportsAllocatedBytes() {
+    return true;
+  }
+
+  @Override
+  public long getThreadAllocatedBytes(long threadId) {
+    return sys.getThreadAllocatedBytes(threadId);
+  }
+
+  @Override
+  public long[] getAllThreadsAllocatedBytes(long[] threadIds) {
+    return sys.getThreadAllocatedBytes(threadIds);
+  }
+
+  @Override
+  public long[] getAllThreadIds() {
+    return sys.getAllThreadIds();
+  }
 }
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index a057e66..d0d03b5 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -122,6 +122,8 @@
     extractMailExample("DeleteReviewerHtml.soy");
     extractMailExample("DeleteVote.soy");
     extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Email.soy");
+    extractMailExample("EmailHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("ChangeHeader.soy");
diff --git a/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index c5e8567..37cf20e 100644
--- a/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -95,6 +95,7 @@
       }
     }
 
+    @SuppressWarnings("DoNotCall")
     void manualShutdown() {
       Runtime.getRuntime().removeShutdownHook(this);
       run();
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 104a44a..d68f809 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -14,10 +14,6 @@
     "account/externalids/testing/ExternalIdTestUtil.java",
 ]
 
-PROJECT_UTIL_SRC = [
-    "ProjectUtil.java",
-]
-
 PROLOG_SRC = ["rules/prolog/*.java"]
 
 java_library(
@@ -26,12 +22,6 @@
     visibility = ["//visibility:public"],
 )
 
-java_library(
-    name = "project_util",
-    srcs = PROJECT_UTIL_SRC,
-    visibility = ["//visibility:public"],
-)
-
 # Giant kitchen-sink target.
 #
 # The only reason this hasn't been split up further is because we have too many
@@ -43,14 +33,13 @@
     srcs = glob(
         ["**/*.java"],
         exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC +
-                  PROJECT_UTIL_SRC + PROLOG_SRC,
+                  PROLOG_SRC,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
     deps = [
         ":constants",
-        ":project_util",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 2d18054..cf04029 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -49,10 +49,12 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import java.util.NavigableSet;
 import java.util.Objects;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -217,6 +219,29 @@
   }
 
   /**
+   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
+   * {@code caller} user.
+   */
+  public Set<Change.Id> areStarred(
+      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
+    List<String> starRefs =
+        changeIds.stream()
+            .map(c -> RefNames.refsStarredChanges(c, caller))
+            .collect(Collectors.toList());
+    try {
+      return allUsersRepo.getRefDatabase().exactRef(starRefs.toArray(new String[0])).keySet()
+          .stream()
+          .map(r -> Change.Id.fromAllUsersRef(r))
+          .collect(Collectors.toSet());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed getting starred changes for account %d within changes: %s",
+          caller.get(), Joiner.on(", ").join(changeIds));
+      return ImmutableSet.of();
+    }
+  }
+
+  /**
    * Unstar the given change for all users.
    *
    * <p>Intended for use only when we're about to delete a change. For that reason, the change is
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 66a36f6..9fec1fa 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -122,13 +122,6 @@
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
-        // Get the default preferences for this Gerrit host
-        Ref ref = allUsers.exactRef(RefNames.REFS_USERS_DEFAULT);
-        CachedPreferences defaultPreferences =
-            ref != null
-                ? defaultPreferenceCache.get(ref.getObjectId())
-                : DefaultPreferencesCache.EMPTY;
-
         Set<CachedAccountDetails.Key> keys =
             Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
@@ -138,6 +131,7 @@
           }
           keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
         }
+        CachedPreferences defaultPreferences = defaultPreferenceCache.get();
         ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
         for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
             accountDetailsCache.getAll(keys).entrySet()) {
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index a1e45e9..dc7fa24 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -18,8 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index bde7404..39cec8b 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -5,8 +5,6 @@
     srcs = glob(
         ["**/*.java"],
     ),
-    resource_strip_prefix = "resources",
-    resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/entities",
diff --git a/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java b/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java
new file mode 100644
index 0000000..82c2856b
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 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.
+
+package com.google.gerrit.server.cache;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.internal.storage.file.RefDirectory;
+import org.eclipse.jgit.lib.RefDatabase;
+
+/** A per request thread cache of RefDatabases by directory (Project). */
+public class PerThreadRefDbCache {
+  protected static final PerThreadCache.Key<PerThreadRefDbCache> REFDB_CACHE_KEY =
+      PerThreadCache.Key.create(PerThreadRefDbCache.class);
+
+  public static RefDatabase getRefDatabase(File path, RefDatabase refDb) {
+    if (PerThreadCache.get() != null) {
+      return PerThreadCache.get()
+          .get(REFDB_CACHE_KEY, PerThreadRefDbCache::new)
+          .computeIfAbsent(path, p -> ((RefDirectory) refDb).createSnapshottingRefDirectory());
+    }
+    return refDb;
+  }
+
+  protected final Map<File, RefDatabase> refDbByRefsDir = new HashMap<>();
+
+  public RefDatabase computeIfAbsent(
+      File path, Function<? super File, ? extends RefDatabase> mappingFunction) {
+    return refDbByRefsDir.computeIfAbsent(path, mappingFunction);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 10e1f92..621994d 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -25,9 +25,10 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
-import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.EmailModule.AbandonedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -38,7 +39,7 @@
 public class AbandonOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AbandonedSender.Factory abandonedSenderFactory;
+  private final AbandonedChangeEmailFactories abandonedChangeEmailFactories;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
@@ -58,14 +59,14 @@
 
   @Inject
   AbandonOp(
-      AbandonedSender.Factory abandonedSenderFactory,
+      AbandonedChangeEmailFactories abandonedChangeEmailFactories,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
       MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
-    this.abandonedSenderFactory = abandonedSenderFactory;
+    this.abandonedChangeEmailFactories = abandonedChangeEmailFactories;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
@@ -111,16 +112,16 @@
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
-      ReplyToChangeSender emailSender =
-          abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      ChangeEmail changeEmail =
+          abandonedChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
+      changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+      OutgoingEmail email = abandonedChangeEmailFactories.createEmail(changeEmail);
       if (accountState != null) {
-        emailSender.setFrom(accountState.account().id());
+        email.setFrom(accountState.account().id());
       }
-      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-      emailSender.send();
+      email.setNotify(notify);
+      email.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index e5a9534..00673d5 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -123,6 +123,10 @@
         changeInfo.removedFromAttentionSet == null
             ? null
             : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
+    copy.customKeyedValues =
+        changeInfo.customKeyedValues == null
+            ? null
+            : ImmutableMap.copyOf(changeInfo.customKeyedValues);
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
     copy.submitType = changeInfo.submitType;
@@ -147,6 +151,7 @@
     copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
     copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
+    copy.tripletId = changeInfo.tripletId;
     copy.cherryPickOfChange = changeInfo.cherryPickOfChange;
     copy.cherryPickOfPatchSet = changeInfo.cherryPickOfPatchSet;
     return copy;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index ec90bec..f0a70bb 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_ADDED;
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -37,7 +37,6 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
   private final Account.Id attentionUserId;
@@ -56,15 +55,12 @@
   @Inject
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      AddToAttentionSetSender.Factory addToAttentionSetSender,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.addToAttentionSetSender = addToAttentionSetSender;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
-
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
     this.notify = notify;
@@ -97,13 +93,6 @@
     if (!notify) {
       return;
     }
-    attentionSetEmailFactory
-        .create(
-            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-            ctx,
-            change,
-            reason,
-            attentionUserId)
-        .sendAsync();
+    attentionSetEmailFactory.create(USER_ADDED, ctx, change, reason, attentionUserId).sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 8773bb7..4273a72 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -63,8 +63,11 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.EmailModule.StartReviewChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -110,7 +113,7 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final StartReviewChangeEmailFactories startReviewChangeEmailFactories;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
@@ -162,7 +165,7 @@
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      CreateChangeSender.Factory createChangeSenderFactory,
+      StartReviewChangeEmailFactories startReviewChangeEmailFactories,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
@@ -180,7 +183,7 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.createChangeSenderFactory = createChangeSenderFactory;
+    this.startReviewChangeEmailFactories = startReviewChangeEmailFactories;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
@@ -531,24 +534,30 @@
             @Override
             public void run() {
               try {
-                CreateChangeSender emailSender =
-                    createChangeSenderFactory.create(change.getProject(), change.getId());
-                emailSender.setFrom(change.getOwner());
-                emailSender.setPatchSet(patchSet, patchSetInfo);
-                emailSender.setNotify(notify);
-                emailSender.addReviewers(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    startReviewChangeEmailFactories.createStartReviewChangeEmail();
+                startReviewEmail.markAsCreateChange();
+                startReviewEmail.addReviewers(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
-                emailSender.addReviewersByEmail(
+                startReviewEmail.addReviewersByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
-                emailSender.addExtraCC(
+                startReviewEmail.addExtraCC(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
-                emailSender.addExtraCCByEmail(
+                startReviewEmail.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
-                emailSender.setMessageId(
+                ChangeEmail changeEmail =
+                    startReviewChangeEmailFactories.createChangeEmail(
+                        change.getProject(), change.getId(), startReviewEmail);
+                changeEmail.setPatchSet(patchSet, patchSetInfo);
+                OutgoingEmail outgoingEmail =
+                    startReviewChangeEmailFactories.createEmail(changeEmail);
+                outgoingEmail.setFrom(change.getOwner());
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
                     "Cannot send email for new change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index f733a7b..4875978 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.CUSTOM_KEYED_VALUES;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
@@ -30,6 +31,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
+import static com.google.gerrit.extensions.client.ListChangesOption.STAR;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
@@ -99,8 +101,12 @@
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -130,6 +136,7 @@
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -219,12 +226,15 @@
     }
   }
 
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
+  private final StarredChangesUtil starredChangesUtil;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
   private final ChangeNotes.Factory notesFactory;
@@ -240,14 +250,19 @@
 
   private AccountLoader accountLoader;
   private FixInput fix;
+  private ExperimentFeatures experimentFeatures;
 
   @Inject
   ChangeJson(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      ExperimentFeatures experimentFeatures,
       Provider<CurrentUser> user,
       PermissionBackend permissionBackend,
       ChangeData.Factory cdf,
       AccountLoader.Factory ailf,
       ChangeMessagesUtil cmUtil,
+      StarredChangesUtil starredChangesUtil,
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
@@ -259,11 +274,15 @@
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.experimentFeatures = experimentFeatures;
     this.userProvider = user;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
     this.accountLoaderFactory = ailf;
     this.cmUtil = cmUtil;
+    this.starredChangesUtil = starredChangesUtil;
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
@@ -422,10 +441,17 @@
     return info;
   }
 
-  private static void finish(ChangeInfo info) {
-    info.id =
+  private static void finish(ChangeInfo info, ExperimentFeatures experimentFeatures) {
+    info.tripletId =
         Joiner.on('~')
             .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
+    if (experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_RETURN_NEW_CHANGE_INFO_ID)) {
+      info.id =
+          Joiner.on('~').join(Url.encode(info.project), Url.encode(String.valueOf(info._number)));
+    } else {
+      info.id = info.tripletId;
+    }
   }
 
   private static boolean containsAnyOf(
@@ -526,6 +552,9 @@
               "Omitting corrupt change %s from results", cd.getId());
         }
       }
+      if (has(STAR)) {
+        populateStarField(changeInfos);
+      }
       return changeInfos;
     }
   }
@@ -563,7 +592,7 @@
       info.isPrivate = c.isPrivate() ? true : null;
       info.workInProgress = c.isWorkInProgress() ? true : null;
       info.hasReviewStarted = c.hasReviewStarted();
-      finish(info);
+      finish(info, experimentFeatures);
     } else {
       info._number = result.id().get();
       info.problems = result.problems();
@@ -617,6 +646,9 @@
                       a -> a.account().get(),
                       a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
     }
+    if (has(CUSTOM_KEYED_VALUES)) {
+      out.customKeyedValues = cd.customKeyedValues();
+    }
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
     if (in.isNew()) {
@@ -727,7 +759,7 @@
     if (needMessages) {
       out.messages = messages(cd);
     }
-    finish(out);
+    finish(out, experimentFeatures);
 
     // This block must come after the ChangeInfo is mostly populated, since
     // it will be passed to ActionVisitors as-is.
@@ -940,6 +972,25 @@
     return map.build();
   }
 
+  /** Populate the 'starred' field. */
+  private void populateStarField(List<ChangeInfo> changeInfos) {
+    // We populate the 'starred' field for all change infos together so that we open the All-Users
+    // repository only once
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Change.Id> changeIds =
+          changeInfos.stream().map(c -> Change.id(c._number)).collect(Collectors.toList());
+      Set<Change.Id> starredChanges =
+          starredChangesUtil.areStarred(
+              allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
+      if (starredChanges.isEmpty()) {
+        return;
+      }
+      changeInfos.stream().forEach(c -> c.starred = starredChanges.contains(Change.id(c._number)));
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to open All-Users repo.");
+    }
+  }
+
   private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
     return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
   }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index f3fd68e..294049f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -19,8 +19,11 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.EmailModule.DeleteReviewerChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
@@ -35,7 +38,7 @@
     DeleteReviewerByEmailOp create(Address reviewer);
   }
 
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories;
   private final MessageIdGenerator messageIdGenerator;
   private final ChangeMessagesUtil changeMessagesUtil;
 
@@ -45,11 +48,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories,
       MessageIdGenerator messageIdGenerator,
       ChangeMessagesUtil changeMessagesUtil,
       @Assisted Address reviewer) {
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.deleteReviewerChangeEmailFactories = deleteReviewerChangeEmailFactories;
     this.messageIdGenerator = messageIdGenerator;
     this.changeMessagesUtil = changeMessagesUtil;
     this.reviewer = reviewer;
@@ -76,15 +79,19 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        DeleteReviewerSender emailSender =
-            deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.addReviewersByEmail(Collections.singleton(reviewer));
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
+        DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
+            deleteReviewerChangeEmailFactories.createDeleteReviewerChangeEmail();
+        deleteReviewerEmail.addReviewersByEmail(Collections.singleton(reviewer));
+        ChangeEmail changeEmail =
+            deleteReviewerChangeEmailFactories.createChangeEmail(
+                ctx.getProject(), change.getId(), deleteReviewerEmail);
+        changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+        OutgoingEmail outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index fc07592..f961d61 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -36,8 +36,11 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.EmailModule.DeleteReviewerChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -68,7 +71,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
@@ -89,7 +92,7 @@
       ChangeMessagesUtil cmUtil,
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +104,7 @@
     this.cmUtil = cmUtil;
     this.reviewerDeleted = reviewerDeleted;
     this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.deleteReviewerChangeEmailFactories = deleteReviewerChangeEmailFactories;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
@@ -250,14 +253,18 @@
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
-    DeleteReviewerSender emailSender =
-        deleteReviewerSenderFactory.create(projectName, change.getId());
-    emailSender.setFrom(userId);
-    emailSender.addReviewers(Collections.singleton(reviewer.id()));
-    emailSender.setChangeMessage(mailMessage, timestamp.toInstant());
-    emailSender.setNotify(notify);
-    emailSender.setMessageId(
+    DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
+        deleteReviewerChangeEmailFactories.createDeleteReviewerChangeEmail();
+    deleteReviewerEmail.addReviewers(Collections.singleton(reviewer.id()));
+    ChangeEmail changeEmail =
+        deleteReviewerChangeEmailFactories.createChangeEmail(
+            projectName, change.getId(), deleteReviewerEmail);
+    changeEmail.setChangeMessage(mailMessage, timestamp.toInstant());
+    OutgoingEmail outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
+    outgoingEmail.setFrom(userId);
+    outgoingEmail.setNotify(notify);
+    outgoingEmail.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
-    emailSender.send();
+    outgoingEmail.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index f67ce4a..f695a56 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -28,9 +28,12 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.EmailModule.ReplacePatchSetChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.RequestContext;
@@ -71,7 +74,7 @@
   EmailNewPatchSet(
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ThreadLocalRequestContext threadLocalRequestContext,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      ReplacePatchSetChangeEmailFactories replacePatchSetChangeEmailFactories,
       PatchSetInfoFactory patchSetInfoFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
@@ -107,7 +110,7 @@
     this.asyncSender =
         new AsyncSender(
             postUpdateContext.getIdentifiedUser(),
-            replacePatchSetFactory,
+            replacePatchSetChangeEmailFactories,
             patchSetInfoFactory,
             messageId,
             postUpdateContext.getNotify(changeId),
@@ -153,7 +156,7 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final IdentifiedUser user;
-    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final ReplacePatchSetChangeEmailFactories replacePatchSetChangeEmailFactories;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final MessageId messageId;
     private final NotifyResolver.Result notify;
@@ -172,7 +175,7 @@
 
     AsyncSender(
         IdentifiedUser user,
-        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        ReplacePatchSetChangeEmailFactories replacePatchSetChangeEmailFactories,
         PatchSetInfoFactory patchSetInfoFactory,
         MessageId messageId,
         NotifyResolver.Result notify,
@@ -188,7 +191,7 @@
         ObjectId preUpdateMetaId,
         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
       this.user = user;
-      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.replacePatchSetChangeEmailFactories = replacePatchSetChangeEmailFactories;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.messageId = messageId;
       this.notify = notify;
@@ -208,22 +211,26 @@
     @Override
     public void run() {
       try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(
+        ReplacePatchSetChangeEmailDecorator replacePatchSetEmail =
+            replacePatchSetChangeEmailFactories.createReplacePatchSetChangeEmail(
                 projectName,
                 changeId,
                 changeKind,
                 preUpdateMetaId,
                 postUpdateSubmitRequirementResults);
-        emailSender.setFrom(user.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
-        emailSender.setChangeMessage(message, timestamp);
-        emailSender.setNotify(notify);
-        emailSender.addReviewers(reviewers);
-        emailSender.addExtraCC(extraCcs);
-        emailSender.addOutdatedApproval(outdatedApprovals);
-        emailSender.setMessageId(messageId);
-        emailSender.send();
+        replacePatchSetEmail.addReviewers(reviewers);
+        replacePatchSetEmail.addExtraCC(extraCcs);
+        replacePatchSetEmail.addOutdatedApproval(outdatedApprovals);
+        ChangeEmail changeEmail =
+            replacePatchSetChangeEmailFactories.createChangeEmail(
+                projectName, changeId, replacePatchSetEmail);
+        changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        changeEmail.setChangeMessage(message, timestamp);
+        OutgoingEmail outgoingEmail = replacePatchSetChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(user.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
       }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index a9886c7..67e09b0 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -28,9 +28,12 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.EmailModule.CommentChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
@@ -84,7 +87,7 @@
   EmailReviewComments(
       @SendEmailExecutor ExecutorService executor,
       PatchSetInfoFactory patchSetInfoFactory,
-      CommentSender.Factory commentSenderFactory,
+      CommentChangeEmailFactories commentChangeEmailFactories,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
@@ -118,7 +121,7 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
-            commentSenderFactory,
+            commentChangeEmailFactories,
             patchSetInfoFactory,
             postUpdateContext.getUser().asIdentifiedUser(),
             messageId,
@@ -149,7 +152,7 @@
   // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
   private static class AsyncSender implements Runnable, RequestContext {
     private final ThreadLocalRequestContext requestContext;
-    private final CommentSender.Factory commentSenderFactory;
+    private final CommentChangeEmailFactories commentChangeEmailFactories;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final IdentifiedUser user;
     private final MessageId messageId;
@@ -168,7 +171,7 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
-        CommentSender.Factory commentSenderFactory,
+        CommentChangeEmailFactories commentChangeEmailFactories,
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser user,
         MessageId messageId,
@@ -184,7 +187,7 @@
         ImmutableList<LabelVote> labels,
         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
       this.requestContext = requestContext;
-      this.commentSenderFactory = commentSenderFactory;
+      this.commentChangeEmailFactories = commentChangeEmailFactories;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.user = user;
       this.messageId = messageId;
@@ -205,18 +208,22 @@
     public void run() {
       RequestContext old = requestContext.setContext(this);
       try {
-        CommentSender emailSender =
-            commentSenderFactory.create(
+        CommentChangeEmailDecorator commentChangeEmail =
+            commentChangeEmailFactories.createCommentChangeEmail(
                 projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
-        emailSender.setFrom(user.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
-        emailSender.setChangeMessage(message, timestamp);
-        emailSender.setComments(comments);
-        emailSender.setPatchSetComment(patchSetComment);
-        emailSender.setLabels(labels);
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(messageId);
-        emailSender.send();
+        commentChangeEmail.setComments(comments);
+        commentChangeEmail.setPatchSetComment(patchSetComment);
+        commentChangeEmail.setLabels(labels);
+        ChangeEmail changeEmail =
+            commentChangeEmailFactories.createChangeEmail(
+                projectName, changeId, commentChangeEmail);
+        changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        changeEmail.setChangeMessage(message, timestamp);
+        OutgoingEmail outgoingEmail = commentChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(user.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
       } finally {
diff --git a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index cb747f6..5f2a5fd 100644
--- a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -24,8 +24,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.EmailModule.StartReviewChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -36,16 +39,16 @@
 public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
+  private final StartReviewChangeEmailFactories startReviewChangeEmailFactories;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   ModifyReviewersEmail(
-      ModifyReviewerSender.Factory addReviewerSenderFactory,
+      StartReviewChangeEmailFactories startReviewChangeEmailFactories,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.startReviewChangeEmailFactories = startReviewChangeEmailFactories;
     this.sendEmailsExecutor = sendEmailsExecutor;
     this.messageIdGenerator = messageIdGenerator;
   }
@@ -90,20 +93,25 @@
         sendEmailsExecutor.submit(
             () -> {
               try {
-                ModifyReviewerSender emailSender =
-                    addReviewerSenderFactory.create(projectNameKey, cId);
-                emailSender.setNotify(notify);
-                emailSender.setFrom(userId);
-                emailSender.addReviewers(immutableToMail);
-                emailSender.addReviewersByEmail(immutableAddedByEmail);
-                emailSender.addExtraCC(immutableToCopy);
-                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
-                emailSender.addRemovedReviewers(immutableToRemove);
-                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
-                emailSender.setMessageId(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    startReviewChangeEmailFactories.createStartReviewChangeEmail();
+                startReviewEmail.addReviewers(immutableToMail);
+                startReviewEmail.addReviewersByEmail(immutableAddedByEmail);
+                startReviewEmail.addExtraCC(immutableToCopy);
+                startReviewEmail.addExtraCCByEmail(immutableCopiedByEmail);
+                startReviewEmail.addRemovedReviewers(immutableToRemove);
+                startReviewEmail.addRemovedByEmailReviewers(immutableRemovedByEmail);
+                ChangeEmail changeEmail =
+                    startReviewChangeEmailFactories.createChangeEmail(
+                        projectNameKey, cId, startReviewEmail);
+                OutgoingEmail outgoingEmail =
+                    startReviewChangeEmailFactories.createEmail(changeEmail);
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setFrom(userId);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
                     "Cannot send email to new reviewers of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 1d92521..5930f7a 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_REMOVED;
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.entities.Account;
@@ -21,7 +22,6 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -39,7 +39,6 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
   private final Account.Id attentionUserId;
@@ -58,13 +57,11 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
@@ -97,13 +94,6 @@
     if (!notify) {
       return;
     }
-    attentionSetEmailFactory
-        .create(
-            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-            ctx,
-            change,
-            reason,
-            attentionUserId)
-        .sendAsync();
+    attentionSetEmailFactory.create(USER_REMOVED, ctx, change, reason, attentionUserId).sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 55161f0..b823115 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -169,7 +169,6 @@
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.mail.send.MailSoySauceModule;
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
@@ -305,7 +304,6 @@
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
-    factory(InboundEmailRejectionSender.Factory.class);
     factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
diff --git a/java/com/google/gerrit/server/config/GerritServerConfig.java b/java/com/google/gerrit/server/config/GerritServerConfig.java
index ead0d63..484c1e9 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfig.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfig.java
@@ -16,8 +16,8 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
+import javax.inject.Qualifier;
 
 /**
  * Marker on {@link org.eclipse.jgit.lib.Config} holding {@code gerrit.config} .
@@ -26,5 +26,5 @@
  * Gerrit Code Review server.
  */
 @Retention(RUNTIME)
-@BindingAnnotation
+@Qualifier
 public @interface GerritServerConfig {}
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index cd49ea6..093b87c 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -34,6 +34,7 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TotalHits;
 import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
@@ -84,10 +85,10 @@
       // We don't have much documentation, so we just use MAX_VALUE here and skip paging.
       TopDocs results = searcher.search(query, Integer.MAX_VALUE);
       ScoreDoc[] hits = results.scoreDocs;
-      long totalHits = results.totalHits;
+      TotalHits totalHits = results.totalHits;
 
       List<DocResult> out = new ArrayList<>();
-      for (int i = 0; i < totalHits; i++) {
+      for (int i = 0; i < totalHits.value; i++) {
         DocResult result = new DocResult();
         Document doc = searcher.doc(hits[i].doc);
         result.url = doc.get(Constants.URL_FIELD);
diff --git a/java/com/google/gerrit/server/events/CommentAddedEvent.java b/java/com/google/gerrit/server/events/CommentAddedEvent.java
index dbbebe8..d59ab08d2 100644
--- a/java/com/google/gerrit/server/events/CommentAddedEvent.java
+++ b/java/com/google/gerrit/server/events/CommentAddedEvent.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
@@ -23,7 +24,7 @@
   static final String TYPE = "comment-added";
   public Supplier<AccountAttribute> author;
   public Supplier<ApprovalAttribute[]> approvals;
-  public String comment;
+  @Nullable public String comment;
 
   public CommentAddedEvent(Change change) {
     super(TYPE, change);
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
index 227deb5..8de5db3 100644
--- a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.experiments;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -57,6 +58,11 @@
   }
 
   @Override
+  public boolean isFeatureEnabled(String featureFlag, Project.NameKey project) {
+    return isFeatureEnabled(featureFlag);
+  }
+
+  @Override
   public ImmutableSet<String> getEnabledExperimentFeatures() {
     return enabledExperimentFeatures;
   }
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
index dc9148a..fd885ed 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.experiments;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 
 /**
  * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
@@ -37,6 +38,12 @@
   boolean isFeatureEnabled(String featureFlag);
 
   /**
+   * Same {@link #isFeatureEnabled}, but takes into account {@code project}, when evaluating the
+   * experiment.
+   */
+  boolean isFeatureEnabled(String featureFlag, Project.NameKey project);
+
+  /**
    * Returns the names of the features that are enabled on Gerrit instance (either by default or via
    * gerrit.config).
    */
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index e294d55..fd7d504 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -29,4 +29,11 @@
   /** On BatchUpdate, do not await index completion before returning to the user */
   public static String GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
       "GerritBackendFeature__do_not_await_change_indexing";
+
+  /**
+   * Sets ChangeInfo.id to "'<project>~<_number>'", instead of "'<project>~<branch>~<Change-Id>'",
+   * spearing an index lookup if the id is used in the follow-up API calls.
+   */
+  public static String GERRIT_BACKEND_FEATURE_RETURN_NEW_CHANGE_INFO_ID =
+      "GerritBackendFeature__return_new_change_info_id";
 }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 79544f2..c6661bd 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -54,7 +55,7 @@
       ChangeData changeData,
       PatchSet ps,
       AccountState author,
-      String comment,
+      @Nullable String comment,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
       Instant when) {
@@ -86,7 +87,7 @@
   /** Event to be fired when a comment or vote has been added to a change. */
   private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
 
-    private final String comment;
+    @Nullable private final String comment;
     private final Map<String, ApprovalInfo> approvals;
     private final Map<String, ApprovalInfo> oldApprovals;
 
@@ -94,7 +95,7 @@
         ChangeInfo change,
         RevisionInfo revision,
         AccountInfo author,
-        String comment,
+        @Nullable String comment,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
         Instant when) {
@@ -105,6 +106,7 @@
     }
 
     @Override
+    @Nullable
     public String getComment() {
       return comment;
     }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index ffb6c66..40d714d 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -43,8 +43,10 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.EmailModule.RevertedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -93,7 +95,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeInserter.Factory changeInserterFactory;
   private final NotifyResolver notifyResolver;
-  private final RevertedSender.Factory revertedSenderFactory;
+  private final RevertedChangeEmailFactories revertedChangeEmailFactories;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeReverted changeReverted;
@@ -108,7 +110,7 @@
       ApprovalsUtil approvalsUtil,
       ChangeInserter.Factory changeInserterFactory,
       NotifyResolver notifyResolver,
-      RevertedSender.Factory revertedSenderFactory,
+      RevertedChangeEmailFactories revertedChangeEmailFactories,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
@@ -120,7 +122,7 @@
     this.approvalsUtil = approvalsUtil;
     this.changeInserterFactory = changeInserterFactory;
     this.notifyResolver = notifyResolver;
-    this.revertedSenderFactory = revertedSenderFactory;
+    this.revertedChangeEmailFactories = revertedChangeEmailFactories;
     this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.changeReverted = changeReverted;
@@ -381,14 +383,16 @@
           ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
       changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        RevertedSender emailSender =
-            revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(revertedChangeId));
-        emailSender.setMessageId(
+        ChangeEmail changeEmail =
+            revertedChangeEmailFactories.createChangeEmail(
+                ctx.getProject(), revertedChange.getId());
+        OutgoingEmail outgoingEmail = revertedChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setNotify(ctx.getNotify(revertedChangeId));
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(
                 ctx.getRepoView(), revertedChange.currentPatchSet().id()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", revertedChangeId);
diff --git a/java/com/google/gerrit/server/git/DynamicRefDbRepository.java b/java/com/google/gerrit/server/git/DynamicRefDbRepository.java
new file mode 100644
index 0000000..2e81ad4
--- /dev/null
+++ b/java/com/google/gerrit/server/git/DynamicRefDbRepository.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2023 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.
+
+package com.google.gerrit.server.git;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.function.BiFunction;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+/** A FileRepository with a dynamic RefDatabase supplied via a BiFunction. */
+public class DynamicRefDbRepository extends FileRepository {
+  public static class FileKey extends RepositoryCache.FileKey {
+    private BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier;
+
+    public static FileKey lenient(
+        File directory, FS fs, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier) {
+      final File gitdir = resolve(directory, fs);
+      return new FileKey(gitdir != null ? gitdir : directory, fs, refDatabaseSupplier);
+    }
+
+    private final FS fs;
+
+    /**
+     * @param directory exact location of the repository.
+     * @param fs the file system abstraction which will be necessary to perform certain file system
+     *     operations.
+     */
+    public FileKey(
+        File directory, FS fs, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier) {
+      super(canonical(directory), fs);
+      this.fs = fs;
+      this.refDatabaseSupplier = refDatabaseSupplier;
+    }
+
+    @Override
+    public Repository open(boolean mustExist) throws IOException {
+      if (mustExist && !isGitRepository(getFile(), fs))
+        throw new RepositoryNotFoundException(getFile());
+      return new DynamicRefDbRepository(getFile(), refDatabaseSupplier);
+    }
+
+    private static File canonical(File path) {
+      try {
+        return path.getCanonicalFile();
+      } catch (IOException e) {
+        return path.getAbsoluteFile();
+      }
+    }
+  }
+
+  private final File path;
+  private final BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier;
+
+  public DynamicRefDbRepository(
+      File path, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier)
+      throws IOException {
+    super(path);
+    this.path = path;
+    this.refDatabaseSupplier = refDatabaseSupplier;
+  }
+
+  @Override
+  public RefDatabase getRefDatabase() {
+    return refDatabaseSupplier.apply(path, super.getRefDatabase());
+  }
+}
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 57d37fa..5eb913d 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.cache.PerThreadRefDbCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
@@ -112,6 +113,7 @@
 
   private final Path basePath;
   private final Map<Project.NameKey, FileKey> fileKeyByProject = new ConcurrentHashMap<>();
+  private final boolean usePerRequestRefCache;
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -119,6 +121,7 @@
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
+    usePerRequestRefCache = cfg.getBoolean("core", null, "usePerRequestRefCache", true);
   }
 
   /**
@@ -168,7 +171,13 @@
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
-    FileKey location = FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
+    FileKey location =
+        usePerRequestRefCache
+            ? DynamicRefDbRepository.FileKey.lenient(
+                getBasePath(name).resolve(name.get()).toFile(),
+                FS.DETECTED,
+                (path, refDb) -> PerThreadRefDbCache.getRefDatabase(path, refDb))
+            : FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
     try {
       Repository repo = RepositoryCache.open(location);
       fileKeyByProject.put(name, location);
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 426f8db..8844c1e 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -26,8 +26,10 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.EmailModule.MergedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -67,7 +69,7 @@
   private final RequestScopePropagator requestScopePropagator;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final ChangeMessagesUtil cmUtil;
-  private final MergedSender.Factory mergedSenderFactory;
+  private final MergedChangeEmailFactories mergedChangeEmailFactories;
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
@@ -88,7 +90,7 @@
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
-      MergedSender.Factory mergedSenderFactory,
+      MergedChangeEmailFactories mergedChangeEmailFactories,
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
@@ -100,7 +102,7 @@
       @Assisted("mergeResultRevId") String mergeResultRevId) {
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.cmUtil = cmUtil;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.mergedChangeEmailFactories = mergedChangeEmailFactories;
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
@@ -188,16 +190,18 @@
                     try {
                       // The stickyApprovalDiff is always empty here since this is not supported
                       // for direct pushes.
-                      MergedSender emailSender =
-                          mergedSenderFactory.create(
+                      ChangeEmail changeEmail =
+                          mergedChangeEmailFactories.createChangeEmail(
                               ctx.getProject(),
                               psId.changeId(),
                               /* stickyApprovalDiff= */ Optional.empty());
-                      emailSender.setFrom(ctx.getAccountId());
-                      emailSender.setPatchSet(patchSet, info);
-                      emailSender.setMessageId(
+                      changeEmail.setPatchSet(patchSet, info);
+                      OutgoingEmail outgoingEmail =
+                          mergedChangeEmailFactories.createEmail(changeEmail);
+                      outgoingEmail.setFrom(ctx.getAccountId());
+                      outgoingEmail.setMessageId(
                           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                      emailSender.send();
+                      outgoingEmail.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
                           "Cannot send email for submitted patch set %s", psId);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 0e9f7ca..3b08d92 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -260,9 +260,9 @@
  *
  * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the
  * receive-pack protocol that this is based on allows multiple ref updates to be processed at once.
- * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH), and legacy pushes
- * (refs/changes/CHANGE). It is hard to split this class up further, because normal pushes can also
- * result in updates to reviews, through the autoclose mechanism.
+ * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH). It is hard to split
+ * this class up further, because normal pushes can also result in updates to reviews, through the
+ * autoclose mechanism.
  */
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -1038,111 +1038,29 @@
           return;
         }
         try {
-          retryHelper
-              .changeUpdate(
-                  "insertChangesAndPatchSets",
-                  updateFactory -> {
-                    try (BatchUpdate bu =
-                            batchUpdateFactory.create(
-                                project.getNameKey(), user.materializedCopy(), TimeUtil.now());
-                        ObjectInserter ins = repo.newObjectInserter();
-                        ObjectReader reader = ins.newReader();
-                        RevWalk rw = new RevWalk(reader)) {
-                      bu.setRepository(repo, rw, ins);
-                      bu.setRefLogMessage("push");
-                      if (magicBranch != null) {
-                        bu.setNotify(magicBranch.getNotifyForNewChange());
-                      }
-
-                      logger.atFine().log("Adding %d replace requests", newChanges.size());
-                      for (ReplaceRequest replace : replaceByChange.values()) {
-                        replace.addOps(bu, replaceProgress);
-                        if (magicBranch != null) {
-                          bu.setNotifyHandling(
-                              replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
-                          if (magicBranch.shouldPublishComments()) {
-                            bu.addOp(
-                                replace.notes.getChangeId(),
-                                publishCommentsOp.create(replace.psId, project.getNameKey()));
-                            Optional<ChangeNotes> changeNotes =
-                                getChangeNotes(replace.notes.getChangeId());
-                            if (!changeNotes.isPresent()) {
-                              // If not present, no need to update attention set here since this is
-                              // a new change.
-                              continue;
-                            }
-                            List<HumanComment> drafts =
-                                commentsUtil.draftByChangeAuthor(
-                                    changeNotes.get(), user.getAccountId());
-                            if (drafts.isEmpty()) {
-                              // If no comments, attention set shouldn't update since the user
-                              // didn't reply.
-                              continue;
-                            }
-                            replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
-                                bu,
-                                changeNotes.get(),
-                                isReadyForReview(changeNotes.get()),
-                                user,
-                                drafts);
-                          }
-                        }
-                      }
-
-                      logger.atFine().log("Adding %d create requests", newChanges.size());
-                      for (CreateRequest create : newChanges) {
-                        create.addOps(bu);
-                      }
-
-                      logger.atFine().log("Adding %d group update requests", newChanges.size());
-                      updateGroups.forEach(r -> r.addOps(bu));
-
-                      logger.atFine().log("Executing batch");
-                      try {
-                        bu.execute();
-                      } catch (UpdateException e) {
-                        throw asRestApiException(e);
-                      }
-
-                      replaceByChange.values().stream()
-                          .forEach(
-                              req ->
-                                  result.addChange(
-                                      ReceiveCommitsResult.ChangeStatus.REPLACED, req.ontoChange));
-                      newChanges.stream()
-                          .forEach(
-                              req ->
-                                  result.addChange(
-                                      ReceiveCommitsResult.ChangeStatus.CREATED, req.changeId));
-
-                      if (magicBranchCmd != null) {
-                        magicBranchCmd.setResult(OK);
-                      }
-                      for (ReplaceRequest replace : replaceByChange.values()) {
-                        String rejectMessage = replace.getRejectMessage();
-                        if (rejectMessage == null) {
-                          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-                            // Not necessarily the magic branch, so need to set OK on the original
-                            // value.
-                            replace.inputCommand.setResult(OK);
-                          }
-                        } else {
-                          logger.atFine().log("Rejecting due to message from ReplaceOp");
-                          reject(replace.inputCommand, rejectMessage);
-                        }
-                      }
-                    }
-                    return null;
-                  })
-              .defaultTimeoutMultiplier(5)
-              .call();
+          if (!newChanges.isEmpty()) {
+            // TODO: Retry lock failures on new change insertions. The retry will
+            //  likely have to move to a higher layer to be able to achieve that
+            //  due to state that needs to be reset with each retry attempt.
+            insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+          } else {
+            retryHelper
+                .changeUpdate(
+                    "insertPatchSets",
+                    updateFactory -> {
+                      insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+                      return null;
+                    })
+                .defaultTimeoutMultiplier(5)
+                .call();
+          }
         } catch (ResourceConflictException e) {
           addError(e.getMessage());
           reject(magicBranchCmd, "conflict");
         } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
           logger.atFine().withCause(e).log("Rejecting due to client error");
           reject(magicBranchCmd, e.getMessage());
-        } catch (RestApiException | UpdateException e) {
+        } catch (RestApiException | IOException | UpdateException e) {
           throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
         }
 
@@ -1166,6 +1084,90 @@
     }
   }
 
+  private void insertChangesAndPatchSets(
+      ReceiveCommand magicBranchCmd, List<CreateRequest> newChanges, Task replaceProgress)
+      throws RestApiException, IOException {
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                project.getNameKey(), user.materializedCopy(), TimeUtil.now());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins);
+      bu.setRefLogMessage("push");
+      if (magicBranch != null) {
+        bu.setNotify(magicBranch.getNotifyForNewChange());
+      }
+
+      logger.atFine().log("Adding %d replace requests", newChanges.size());
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.addOps(bu, replaceProgress);
+        if (magicBranch != null) {
+          bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
+          if (magicBranch.shouldPublishComments()) {
+            bu.addOp(
+                replace.notes.getChangeId(),
+                publishCommentsOp.create(replace.psId, project.getNameKey()));
+            Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
+            if (!changeNotes.isPresent()) {
+              // If not present, no need to update attention set here since this is
+              // a new change.
+              continue;
+            }
+            List<HumanComment> drafts =
+                commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
+            if (drafts.isEmpty()) {
+              // If no comments, attention set shouldn't update since the user
+              // didn't reply.
+              continue;
+            }
+            replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
+                bu, changeNotes.get(), isReadyForReview(changeNotes.get()), user, drafts);
+          }
+        }
+      }
+
+      logger.atFine().log("Adding %d create requests", newChanges.size());
+      for (CreateRequest create : newChanges) {
+        create.addOps(bu);
+      }
+
+      logger.atFine().log("Adding %d group update requests", newChanges.size());
+      updateGroups.forEach(r -> r.addOps(bu));
+
+      logger.atFine().log("Executing batch");
+      try {
+        bu.execute();
+      } catch (UpdateException e) {
+        throw asRestApiException(e);
+      }
+
+      replaceByChange.values().stream()
+          .forEach(
+              req -> result.addChange(ReceiveCommitsResult.ChangeStatus.REPLACED, req.ontoChange));
+      newChanges.stream()
+          .forEach(
+              req -> result.addChange(ReceiveCommitsResult.ChangeStatus.CREATED, req.changeId));
+
+      if (magicBranchCmd != null) {
+        magicBranchCmd.setResult(OK);
+      }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        String rejectMessage = replace.getRejectMessage();
+        if (rejectMessage == null) {
+          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
+            // Not necessarily the magic branch, so need to set OK on the original
+            // value.
+            replace.inputCommand.setResult(OK);
+          }
+        } else {
+          logger.atFine().log("Rejecting due to message from ReplaceOp");
+          reject(replace.inputCommand, rejectMessage);
+        }
+      }
+    }
+  }
+
   private boolean isReadyForReview(ChangeNotes changeNotes) {
     return (!changeNotes.getChange().isWorkInProgress() && !magicBranch.workInProgress)
         || magicBranch.ready;
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index c31d1b9..1f3dbcb 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -73,11 +73,13 @@
           }
 
           @Override
+          @Nullable
           public String getEmailAddress() {
             return null;
           }
 
           @Override
+          @Nullable
           public String getUrl() {
             return null;
           }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 8e7d964..fd264a1 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -85,7 +85,10 @@
           .build();
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<AccountState> V12 = schema(V11);
+  @Deprecated static final Schema<AccountState> V12 = schema(V11);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<AccountState> V13 = schema(V12);
 
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index dc3907d..f046c7c 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -361,6 +361,7 @@
 
     protected abstract T callImpl() throws Exception;
 
+    @SuppressWarnings("unused")
     protected abstract void remove();
 
     @Override
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index e74ce8f..93521f3 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -239,7 +239,7 @@
           .build();
 
   /** Remove assignee field. */
-  @SuppressWarnings("deprecation")
+  @Deprecated
   static final Schema<ChangeData> V82 =
       new Schema.Builder<ChangeData>()
           .add(V81)
@@ -247,6 +247,10 @@
           .remove(ChangeField.ASSIGNEE_FIELD)
           .build();
 
+  /** Upgrade Lucene to 8.x requires reindexing. */
+  @SuppressWarnings("deprecation")
+  static final Schema<ChangeData> V83 = schema(V82);
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 861a5fa..b52b2d1 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -150,6 +150,7 @@
 
     protected abstract V impl(RequestContext ctx) throws Exception;
 
+    @SuppressWarnings("unused")
     protected abstract void remove();
   }
 
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index f0f3510..1b87d27 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -69,7 +69,10 @@
   @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<InternalGroup> V9 = schema(V8);
+  @Deprecated static final Schema<InternalGroup> V9 = schema(V8);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<InternalGroup> V10 = schema(V9);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 7013e27..1d8cc1e 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.index.group;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Index;
@@ -21,6 +23,7 @@
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.IndexedQuery;
+import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.HashSet;
@@ -31,7 +34,7 @@
  * com.google.gerrit.index.IndexRewriter}. See {@link IndexedQuery}.
  */
 public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, InternalGroup>
-    implements DataSource<InternalGroup> {
+    implements DataSource<InternalGroup>, Matchable<InternalGroup> {
 
   public static QueryOptions createOptions(
       IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
@@ -50,4 +53,20 @@
       throws QueryParseException {
     super(index, pred, opts.convertForBackend());
   }
+
+  @Override
+  public boolean match(InternalGroup object) {
+    Predicate<InternalGroup> pred = getChild(0);
+    checkState(
+        pred.isMatchable(),
+        "match invoked, but child predicate %s doesn't implement %s",
+        pred,
+        Matchable.class.getName());
+    return pred.asMatchable().match(object);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index 50f26bb..340a103 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -14,42 +14,468 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.mail.send.AbandonedSender;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.CommentSender;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
-import com.google.gerrit.server.mail.send.MergedSender;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
-import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.AbandonedChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AddKeyEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.ChangeEmailFactory;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.DeleteKeyEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.DeleteVoteChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.EmailArguments;
+import com.google.gerrit.server.mail.send.HttpPasswordUpdateEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
+import com.google.gerrit.server.mail.send.MergedChangeEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.OutgoingEmailFactory;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.RestoredChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.RevertedChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class EmailModule extends FactoryModule {
-  @Override
-  protected void configure() {
-    factory(AbandonedSender.Factory.class);
-    factory(AddKeySender.Factory.class);
-    factory(ModifyReviewerSender.Factory.class);
-    factory(CommentSender.Factory.class);
-    factory(CreateChangeSender.Factory.class);
-    factory(DeleteKeySender.Factory.class);
-    factory(DeleteReviewerSender.Factory.class);
-    factory(DeleteVoteSender.Factory.class);
-    factory(HttpPasswordUpdateSender.Factory.class);
-    factory(MergedSender.Factory.class);
-    factory(RegisterNewEmailSender.Factory.class);
-    factory(ReplacePatchSetSender.Factory.class);
-    factory(RestoredSender.Factory.class);
-    factory(RevertedSender.Factory.class);
-    factory(AddToAttentionSetSender.Factory.class);
-    factory(RemoveFromAttentionSetSender.Factory.class);
+  public static class AbandonedChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final AbandonedChangeEmailDecorator abandonedChangeEmailDecorator;
+
+    @Inject
+    AbandonedChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        AbandonedChangeEmailDecorator abandonedChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.abandonedChangeEmailDecorator = abandonedChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), abandonedChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("abandon", changeEmail);
+    }
+  }
+
+  public static class AttentionSetChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    AttentionSetChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public AttentionSetChangeEmailDecorator createAttentionSetChangeEmail() {
+      return new AttentionSetChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        AttentionSetChangeEmailDecorator attentionSetChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), attentionSetChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(
+        AttentionSetChange attentionSetChange, ChangeEmail changeEmail) {
+      if (attentionSetChange.equals(AttentionSetChange.USER_ADDED)) {
+        return outgoingEmailFactory.create("addToAttentionSet", changeEmail);
+      } else {
+        return outgoingEmailFactory.create("removeFromAttentionSet", changeEmail);
+      }
+    }
+  }
+
+  public static class CommentChangeEmailFactories {
+    private final EmailArguments args;
+    private final CommentChangeEmailDecoratorFactory commentChangeEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    CommentChangeEmailFactories(
+        EmailArguments args,
+        CommentChangeEmailDecoratorFactory commentChangeEmailFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.commentChangeEmailFactory = commentChangeEmailFactory;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public CommentChangeEmailDecorator createCommentChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      return commentChangeEmailFactory.create(
+          project, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        CommentChangeEmailDecorator commentChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), commentChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("comment", changeEmail);
+    }
+  }
+
+  public static class DeleteReviewerChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    DeleteReviewerChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public DeleteReviewerChangeEmailDecorator createDeleteReviewerChangeEmail() {
+      return new DeleteReviewerChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        DeleteReviewerChangeEmailDecorator deleteReviewerChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), deleteReviewerChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("deleteReviewer", changeEmail);
+    }
+  }
+
+  public static class DeleteVoteChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final DeleteVoteChangeEmailDecorator deleteVoteChangeEmailDecorator;
+
+    @Inject
+    DeleteVoteChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        DeleteVoteChangeEmailDecorator deleteVoteChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.deleteVoteChangeEmailDecorator = deleteVoteChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), deleteVoteChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("deleteVote", changeEmail);
+    }
+  }
+
+  public static class MergedChangeEmailFactories {
+    private final EmailArguments args;
+    private final MergedChangeEmailDecoratorFactory mergedChangeEmailDecoratorFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    MergedChangeEmailFactories(
+        EmailArguments args,
+        MergedChangeEmailDecoratorFactory mergedChangeEmailDecoratorFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.mergedChangeEmailDecoratorFactory = mergedChangeEmailDecoratorFactory;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId),
+          mergedChangeEmailDecoratorFactory.create(stickyApprovalDiff));
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("merged", changeEmail);
+    }
+  }
+
+  public static class ReplacePatchSetChangeEmailFactories {
+    private final EmailArguments args;
+    private final ReplacePatchSetChangeEmailDecoratorFactory
+        replacePatchSetChangeEmailDecoratorFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    ReplacePatchSetChangeEmailFactories(
+        EmailArguments args,
+        ReplacePatchSetChangeEmailDecoratorFactory replacePatchSetChangeEmailDecoratorFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.replacePatchSetChangeEmailDecoratorFactory = replacePatchSetChangeEmailDecoratorFactory;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public ReplacePatchSetChangeEmailDecorator createReplacePatchSetChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      return replacePatchSetChangeEmailDecoratorFactory.create(
+          project, changeId, changeKind, preUpdateMetaId, postUpdateSubmitRequirementResults);
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        ReplacePatchSetChangeEmailDecorator replacePatchSetChangeEmailDecoratorFactory) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), replacePatchSetChangeEmailDecoratorFactory);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("newpatchset", changeEmail);
+    }
+  }
+
+  public static class RestoredChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final RestoredChangeEmailDecorator restoredChangeEmailDecorator;
+
+    @Inject
+    RestoredChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        RestoredChangeEmailDecorator restoredChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.restoredChangeEmailDecorator = restoredChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), restoredChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("restore", changeEmail);
+    }
+  }
+
+  public static class RevertedChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final RevertedChangeEmailDecorator revertedChangeEmailDecorator;
+
+    @Inject
+    RevertedChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        RevertedChangeEmailDecorator revertedChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.revertedChangeEmailDecorator = revertedChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), revertedChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("revert", changeEmail);
+    }
+  }
+
+  public static class StartReviewChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    StartReviewChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public StartReviewChangeEmailDecorator createStartReviewChangeEmail() {
+      return new StartReviewChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        StartReviewChangeEmailDecorator startReviewChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), startReviewChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("newchange", changeEmail);
+    }
+  }
+
+  public static class AddKeyEmailFactories {
+    private final AddKeyEmailDecoratorFactory addKeyEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    AddKeyEmailFactories(
+        AddKeyEmailDecoratorFactory addKeyEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.addKeyEmailDecoratorFactory = addKeyEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, AccountSshKey sshKey) {
+      return outgoingEmailFactory.create(
+          "addkey", addKeyEmailDecoratorFactory.create(user, sshKey));
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, List<String> gpgKeys) {
+      return outgoingEmailFactory.create(
+          "addkey", addKeyEmailDecoratorFactory.create(user, gpgKeys));
+    }
+  }
+
+  public static class DeleteKeyEmailFactories {
+    private final DeleteKeyEmailDecoratorFactory deleteKeyEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    DeleteKeyEmailFactories(
+        DeleteKeyEmailDecoratorFactory deleteKeyEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.deleteKeyEmailDecoratorFactory = deleteKeyEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, AccountSshKey sshKey) {
+      return outgoingEmailFactory.create(
+          "deletekey", deleteKeyEmailDecoratorFactory.create(user, sshKey));
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, List<String> gpgKeyFingerprints) {
+      return outgoingEmailFactory.create(
+          "deletekey", deleteKeyEmailDecoratorFactory.create(user, gpgKeyFingerprints));
+    }
+  }
+
+  public static class HttpPasswordUpdateEmailFactory {
+    private final HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    HttpPasswordUpdateEmailFactory(
+        HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.httpPasswordUpdateEmailDecoratorFactory = httpPasswordUpdateEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, String operation) {
+      return outgoingEmailFactory.create(
+          "HttpPasswordUpdate", httpPasswordUpdateEmailDecoratorFactory.create(user, operation));
+    }
+  }
+
+  public static class InboundEmailRejectionEmailFactory {
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    InboundEmailRejectionEmailFactory(OutgoingEmailFactory outgoingEmailFactory) {
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(Address to, String threadId, InboundEmailError reason) {
+      return outgoingEmailFactory.create(
+          "error", new InboundEmailRejectionEmailDecorator(to, threadId, reason));
+    }
+  }
+
+  public static class RegisterNewEmailFactories {
+    private final RegisterNewEmailDecoratorFactory registerEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    RegisterNewEmailFactories(
+        RegisterNewEmailDecoratorFactory registerEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.registerEmailDecoratorFactory = registerEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public RegisterNewEmailDecorator createRegisterNewEmail(String address) {
+      return registerEmailDecoratorFactory.create(address);
+    }
+
+    public OutgoingEmail createEmail(RegisterNewEmailDecorator registerEmail) {
+      return outgoingEmailFactory.create("registernewemail", registerEmail);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index ead4c06..ea55a24 100644
--- a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -16,9 +16,8 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 public interface EmailTokenVerifier {
   /**
    * Construct a token to verify an email address for a user.
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 36e801b..82ffda2 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -21,14 +21,13 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 @Singleton
 public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
   private final SignedToken emailRegistrationToken;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 93da997..7fe0515 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -56,10 +56,11 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.EmailModule.InboundEmailRejectionEmailFactory;
 import com.google.gerrit.server.mail.MailFilter;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender.InboundEmailError;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -108,7 +109,7 @@
                   CommentForValidation.CommentType.INLINE_COMMENT);
 
   private final Emails emails;
-  private final InboundEmailRejectionSender.Factory emailRejectionSender;
+  private final InboundEmailRejectionEmailFactory inboundEmailRejectionEmailFactory;
   private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
@@ -127,7 +128,7 @@
   @Inject
   public MailProcessor(
       Emails emails,
-      InboundEmailRejectionSender.Factory emailRejectionSender,
+      InboundEmailRejectionEmailFactory inboundEmailRejectionEmailFactory,
       RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
@@ -143,7 +144,7 @@
       PluginSetContext<CommentValidator> commentValidators,
       MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
-    this.emailRejectionSender = emailRejectionSender;
+    this.inboundEmailRejectionEmailFactory = inboundEmailRejectionEmailFactory;
     this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
@@ -228,10 +229,10 @@
 
   private void sendRejectionEmail(MailMessage message, InboundEmailError reason) {
     try {
-      InboundEmailRejectionSender emailSender =
-          emailRejectionSender.create(message.from(), message.id(), reason);
-      emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
-      emailSender.send();
+      OutgoingEmail email =
+          inboundEmailRejectionEmailFactory.createEmail(message.from(), message.id(), reason);
+      email.setMessageId(messageIdGenerator.fromMailMessage(message));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
new file mode 100644
index 0000000..3ec2b35
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2009 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.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+
+/** Send notice about a change being abandoned by its owner. */
+public class AbandonedChangeEmailDecorator implements ChangeEmail.ChangeEmailDecorator {
+  private ChangeEmail changeEmail;
+  private OutgoingEmail email;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ABANDONED_CHANGES);
+
+    email.appendText(email.textTemplate("Abandoned"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AbandonedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
deleted file mode 100644
index e4d6c0d..0000000
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2009 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being abandoned by its owner. */
-public class AbandonedSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<AbandonedSender> {
-    @Override
-    AbandonedSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public AbandonedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "abandon", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ABANDONED_CHANGES);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Abandoned"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AbandonedHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
new file mode 100644
index 0000000..61e73e3
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.List;
+
+/** Informs a user by email about the addition of an SSH or GPG key to their account. */
+@AutoFactory
+public class AddKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = sshKey;
+    this.gpgKeys = null;
+  }
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, List<String> gpgKeys) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = null;
+    this.gpgKeys = gpgKeys;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader(
+        "Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public boolean shouldSendMessage() {
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    return true;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeys", getGpgKeys());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("AddKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AddKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeys != null) {
+      return "GPG";
+    }
+    return "Unknown";
+  }
+
+  @Nullable
+  private String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  @Nullable
+  private String getGpgKeys() {
+    if (gpgKeys != null) {
+      return Joiner.on("\n").join(gpgKeys);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
deleted file mode 100644
index c7b1f2a..0000000
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.List;
-
-/** Sender that informs a user by email about the addition of an SSH or GPG key to their account. */
-public class AddKeySender extends OutgoingEmail {
-  public interface Factory {
-    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeys;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = sshKey;
-    this.gpgKeys = null;
-  }
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeys) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = null;
-    this.gpgKeys = gpgKeys;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
-      // Don't email if no keys were added.
-      return false;
-    }
-
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("AddKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("email", getEmail());
-    addSoyEmailDataParam("gpgKeys", getGpgKeys());
-    addSoyEmailDataParam("keyType", getKeyType());
-    addSoyEmailDataParam("sshKey", getSshKey());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeys != null) {
-      return "GPG";
-    }
-    return "Unknown";
-  }
-
-  @Nullable
-  private String getSshKey() {
-    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
-  }
-
-  @Nullable
-  private String getGpgKeys() {
-    if (gpgKeys != null) {
-      return Joiner.on("\n").join(gpgKeys);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
deleted file mode 100644
index f9ef199..0000000
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Let users know of a new user in the attention set. */
-public class AddToAttentionSetSender extends AttentionSetSender {
-
-  public interface Factory extends ReplyToChangeSender.Factory<AddToAttentionSetSender> {
-    @Override
-    AddToAttentionSetSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public AddToAttentionSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "addToAttentionSet", project, changeId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("AddToAttentionSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddToAttentionSetHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
new file mode 100644
index 0000000..9f1a5a8
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Base class for Attention Set email senders */
+public final class AttentionSetChangeEmailDecorator implements ChangeEmailDecorator {
+  public enum AttentionSetChange {
+    USER_ADDED,
+    USER_REMOVED
+  }
+
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private Account.Id attentionSetUser;
+  private String reason;
+  private AttentionSetChange attentionSetChange;
+
+  public void setAttentionSetUser(Account.Id attentionSetUser) {
+    this.attentionSetUser = attentionSetUser;
+  }
+
+  public void setReason(String reason) {
+    this.reason = reason;
+  }
+
+  public void setAttentionSetChange(AttentionSetChange attentionSetChange) {
+    this.attentionSetChange = attentionSetChange;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("attentionSetUser", email.getNameFor(attentionSetUser));
+    email.addSoyParam("reason", reason);
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.ccExistingReviewers();
+
+    switch (attentionSetChange) {
+      case USER_ADDED:
+        email.appendText(email.textTemplate("AddToAttentionSet"));
+        if (email.useHtml()) {
+          email.appendHtml(email.soyHtmlTemplate("AddToAttentionSetHtml"));
+        }
+        break;
+      case USER_REMOVED:
+        email.appendText(email.textTemplate("RemoveFromAttentionSet"));
+        if (email.useHtml()) {
+          email.appendHtml(email.soyHtmlTemplate("RemoveFromAttentionSetHtml"));
+        }
+        break;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
deleted file mode 100644
index 35a47c9..0000000
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-
-/** Base class for Attention Set email senders */
-public abstract class AttentionSetSender extends ReplyToChangeSender {
-  private Account.Id attentionSetUser;
-  private String reason;
-
-  public AttentionSetSender(
-      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
-    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  public void setAttentionSetUser(Account.Id attentionSetUser) {
-    this.attentionSetUser = attentionSetUser;
-  }
-
-  public void setReason(String reason) {
-    this.reason = reason;
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyParam("attentionSetUser", getNameFor(attentionSetUser));
-    addSoyParam("reason", reason);
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
index 33d0e95..e26f83f 100644
--- a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
+++ b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
@@ -24,7 +24,6 @@
 
 /** Contains utils for email notification related to the events on project+branch. */
 class BranchEmailUtils {
-
   /** Set a reasonable list id so that filters can be used to sort messages. */
   static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
     email.setHeader(
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 154686a..0b909cd 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -17,6 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -33,7 +35,6 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -57,8 +58,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
 import java.text.MessageFormat;
 import java.time.Instant;
 import java.util.Collection;
@@ -70,7 +69,6 @@
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
-import org.apache.http.client.utils.URIBuilder;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -79,54 +77,109 @@
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
 
-/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends OutgoingEmail {
+// TODO: Remove ChangeEmail and rename this class once all usages are migrated to ChangeEmailNew.
+/** Populates an email for change related notifications. */
+@AutoFactory
+public final class ChangeEmail implements OutgoingEmail.EmailDecorator {
+
+  /** Implementations of params interface populate details specific to the notification type. */
+  public interface ChangeEmailDecorator {
+    /**
+     * Stores the reference to the {@link OutgoingEmail} and {@link ChangeEmail} for the subsequent
+     * calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * ChangeEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email, ChangeEmail changeEmail) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() throws EmailException {
+      return true;
+    }
+  }
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(project, id);
-  }
-
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
-    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
-  }
-
+  // Available after construction
+  private final EmailArguments args;
   private final Set<Account.Id> currentAttentionSet;
-  protected final Change change;
-  protected final ChangeData changeData;
-  protected ListMultimap<Account.Id, String> stars;
-  protected PatchSet patchSet;
-  protected PatchSetInfo patchSetInfo;
-  protected String changeMessage;
-  protected Instant timestamp;
-  protected BranchNameKey branch;
+  private final Change change;
+  private final ChangeData changeData;
+  private final BranchNameKey branch;
+  private final ChangeEmailDecorator changeEmailDecorator;
 
-  protected ProjectState projectState;
+  // Available after init or after being explicitly set.
+  private OutgoingEmail email;
+  private ListMultimap<Account.Id, String> stars;
+  private PatchSet patchSet;
+  private PatchSetInfo patchSetInfo;
+  private String changeMessage;
+  private String changeMessageThreadId;
+  private Instant timestamp;
+  private ProjectState projectState;
   private Set<Account.Id> authors;
   private boolean emailOnlyAuthors;
-  protected boolean emailOnlyAttentionSetIfEnabled;
+  private boolean emailOnlyAttentionSetIfEnabled;
   // Watchers ignore attention set rules.
-  protected Set<Account.Id> watcherAccounts = new HashSet<>();
+  private Set<Account.Id> watcherAccounts = new HashSet<>();
   // Watcher can only be an email if it's specified in notify section of ProjectConfig.
-  protected Set<Address> watcherEmails = new HashSet<>();
+  private Set<Address> watcherEmails = new HashSet<>();
+  private boolean isThreadReply = false;
 
-  protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass);
+  public ChangeEmail(
+      @Provided EmailArguments args,
+      ChangeData changeData,
+      ChangeEmailDecorator changeEmailDecorator) {
+    this.args = args;
     this.changeData = changeData;
     change = changeData.change();
     emailOnlyAuthors = false;
     emailOnlyAttentionSetIfEnabled = true;
     currentAttentionSet = getAttentionSet();
     branch = changeData.change().getDest();
+    this.changeEmailDecorator = changeEmailDecorator;
+  }
+
+  public void markAsReply() {
+    isThreadReply = true;
+  }
+
+  public Change getChange() {
+    return change;
+  }
+
+  public ChangeData getChangeData() {
+    return changeData;
+  }
+
+  @Nullable
+  public Instant getTimestamp() {
+    return timestamp;
   }
 
   public void setPatchSet(PatchSet ps) {
     patchSet = ps;
   }
 
+  @Nullable
+  public PatchSet getPatchSet() {
+    return patchSet;
+  }
+
   public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
     patchSet = ps;
     patchSetInfo = psi;
@@ -137,39 +190,28 @@
     timestamp = t;
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  @Override
-  protected void format() throws EmailException {
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
-    }
-    appendText(textTemplate("ChangeHeader"));
-    formatChange();
-    appendText(textTemplate("ChangeFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
-    }
-    formatFooter();
+  public void setEmailOnlyAttentionSetIfEnabled(boolean value) {
+    emailOnlyAttentionSetIfEnabled = value;
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void formatChange() throws EmailException;
-
-  /**
-   * Format the message footer by calling {@link #appendText(String)}.
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void formatFooter() throws EmailException {}
-
-  /** Setup the message headers and envelope (TO, CC, BCC). */
   @Override
-  protected void init() throws EmailException {
-    super.init();
-    if (getFrom() != null) {
+  public boolean shouldSendMessage() throws EmailException {
+    return changeEmailDecorator.shouldSendMessage();
+  }
+
+  @Override
+  public void init(OutgoingEmail email) throws EmailException {
+    this.email = email;
+
+    changeMessageThreadId =
+        String.format(
+            "<gerrit.%s.%s@%s>",
+            change.getCreatedOn().toEpochMilli(), change.getKey().get(), email.getGerritHost());
+
+    if (email.getFrom() != null) {
       // Is the from user in an email squelching group?
       try {
-        args.permissionBackend.absentUser(getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
+        args.permissionBackend.absentUser(email.getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
       } catch (AuthException | PermissionBackendException e) {
         emailOnlyAuthors = true;
       }
@@ -190,7 +232,7 @@
     }
 
     if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
+      email.setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
       if (patchSetInfo == null) {
         try {
           patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
@@ -206,32 +248,34 @@
       throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
     }
 
-    BranchEmailUtils.setListIdHeader(this, branch);
+    BranchEmailUtils.setListIdHeader(email, branch);
     if (timestamp != null) {
-      setHeader(FieldName.DATE, timestamp);
+      email.setHeader(FieldName.DATE, timestamp);
     }
-    setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
-    setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
-    setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
+    email.setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
+    email.setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
+    email.setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
     setChangeUrlHeader();
     setCommitIdHeader();
+
+    changeEmailDecorator.init(email, this);
   }
 
   private void setChangeUrlHeader() {
     final String u = getChangeUrl();
     if (u != null) {
-      setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
+      email.setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
     }
   }
 
   private void setCommitIdHeader() {
     if (patchSet != null) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
+      email.setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     }
   }
 
   private void setChangeSubjectHeader() {
-    setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
+    email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject"));
   }
 
   private int getInsertionsCount() {
@@ -255,25 +299,19 @@
    */
   @Nullable
   public String getChangeUrl() {
-    Optional<String> changeUrl =
-        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
-    if (!changeUrl.isPresent()) return null;
-    try {
-      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
-      return uri.toString();
-    } catch (URISyntaxException e) {
-      return null;
-    }
+    return args.urlFormatter
+        .get()
+        .getChangeViewUrl(change.getProject(), change.getId())
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
-  public String getChangeMessageThreadId() {
-    return "<gerrit."
-        + change.getCreatedOn().toEpochMilli()
-        + "."
-        + change.getKey().get()
-        + "@"
-        + getGerritHost()
-        + ">";
+  /** Sets headers for conversation grouping */
+  private void setThreadHeaders() {
+    if (isThreadReply) {
+      email.setHeader("In-Reply-To", changeMessageThreadId);
+    }
+    email.setHeader("References", changeMessageThreadId);
   }
 
   /** Get the text of the "cover letter". */
@@ -331,7 +369,7 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
+  public Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
     try {
       PatchSet ps;
       if (patchSetId == patchSet.number()) {
@@ -348,7 +386,7 @@
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() {
+  public Map<String, FileDiffOutput> listModifiedFiles() {
     if (patchSet != null) {
       try {
         return args.diffOperations.listModifiedFilesAgainstParent(
@@ -363,37 +401,37 @@
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
-  protected ProjectState getProjectState() {
+  public ProjectState getProjectState() {
     return projectState;
   }
 
   /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void addAuthors(RecipientType rt) {
+  public void addAuthors(RecipientType rt) {
     for (Account.Id id : getAuthors()) {
-      addByAccountId(rt, id);
+      email.addByAccountId(rt, id);
     }
   }
 
   /** BCC any user who has starred this change. */
-  protected void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())) {
+  public void bccStarredBy() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
       return;
     }
 
     for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
       if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        super.addByAccountId(RecipientType.BCC, e.getKey());
+        email.addByAccountId(RecipientType.BCC, e.getKey());
       }
     }
   }
 
   /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type) {
+  public void includeWatchers(NotifyType type) {
     includeWatchers(type, true);
   }
 
   /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+  public void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     try {
       Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
       addWatchers(RecipientType.TO, matching.to);
@@ -411,17 +449,17 @@
   private void addWatchers(RecipientType type, WatcherList watcherList) {
     watcherAccounts.addAll(watcherList.accounts);
     for (Account.Id user : watcherList.accounts) {
-      addByAccountId(type, user);
+      email.addByAccountId(type, user);
     }
 
     watcherEmails.addAll(watcherList.emails);
     for (Address addr : watcherList.emails) {
-      addByEmail(type, addr);
+      email.addByEmail(type, addr);
     }
   }
 
   private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
       return new Watchers();
     }
 
@@ -430,15 +468,15 @@
   }
 
   /** Any user who has published comments on this change. */
-  protected void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(getNotify().handling())) {
+  public void ccAllApprovals() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
       return;
     }
 
     try {
       for (Account.Id id : changeData.reviewers().all()) {
-        addByAccountId(RecipientType.CC, id);
+        email.addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
@@ -446,15 +484,15 @@
   }
 
   /** Users who were added as reviewers to this change. */
-  protected void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(getNotify().handling())) {
+  public void ccExistingReviewers() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
       return;
     }
 
     try {
       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        addByAccountId(RecipientType.CC, id);
+        email.addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
@@ -462,7 +500,7 @@
   }
 
   @Override
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
     if (!projectState.statePermitsRead()) {
       return false;
     }
@@ -483,7 +521,7 @@
   }
 
   @Override
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
     if (!projectState.statePermitsRead()) {
       return false;
     }
@@ -501,18 +539,17 @@
         return false;
       }
     }
-
     return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
   /** Lazily finds all users who are authors of any part of this change. */
-  protected Set<Account.Id> getAuthors() {
+  private Set<Account.Id> getAuthors() {
     if (this.authors != null) {
       return this.authors;
     }
     Set<Account.Id> authors = new HashSet<>();
 
-    switch (getNotify().handling()) {
+    switch (email.getNotify().handling()) {
       case NONE:
         break;
       case ALL:
@@ -539,20 +576,20 @@
   }
 
   @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    BranchEmailUtils.addBranchData(this, args, branch);
+  public void populateEmailContent() throws EmailException {
+    BranchEmailUtils.addBranchData(email, args, branch);
+    setThreadHeaders();
 
-    addSoyParam("changeId", change.getKey().get());
-    addSoyParam("coverLetter", getCoverLetter());
-    addSoyParam("fromName", getNameFor(getFrom()));
-    addSoyParam("fromEmail", getNameEmailFor(getFrom()));
-    addSoyParam("diffLines", getDiffTemplateData(getUnifiedDiff()));
+    email.addSoyParam("changeId", change.getKey().get());
+    email.addSoyParam("coverLetter", getCoverLetter());
+    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
+    email.addSoyParam("diffLines", getDiffTemplateData(getUnifiedDiff()));
 
-    addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
-    addSoyEmailDataParam("changeDetail", getChangeDetail());
-    addSoyEmailDataParam("changeUrl", getChangeUrl());
-    addSoyEmailDataParam("includeDiff", getIncludeDiff());
+    email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
+    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
+    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
+    email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
 
     Map<String, String> changeData = new HashMap<>();
 
@@ -563,56 +600,66 @@
     changeData.put("shortSubject", shortenSubject(subject));
     changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
 
-    changeData.put("ownerName", getNameFor(change.getOwner()));
-    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
+    changeData.put("ownerName", email.getNameFor(change.getOwner()));
+    changeData.put("ownerEmail", email.getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
     changeData.put(
         "sizeBucket",
         ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
-    addSoyParam("change", changeData);
+    email.addSoyParam("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
     patchSetData.put("patchSetId", patchSet.number());
     patchSetData.put("refName", patchSet.refName());
-    addSoyParam("patchSet", patchSetData);
+    email.addSoyParam("patchSet", patchSetData);
 
     Map<String, Object> patchSetInfoData = new HashMap<>();
     patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
     patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
-    addSoyParam("patchSetInfo", patchSetInfoData);
+    email.addSoyParam("patchSetInfo", patchSetInfoData);
 
-    addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
-    addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
-    addFooter(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
+    email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
+    email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
+    email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
+    email.addFooter(MailHeader.OWNER.withDelimiter() + email.getNameEmailFor(change.getOwner()));
     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
+      email.addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
     }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      addFooter(MailHeader.CC.withDelimiter() + reviewer);
+      email.addFooter(MailHeader.CC.withDelimiter() + reviewer);
     }
     for (Account.Id attentionUser : currentAttentionSet) {
-      addFooter(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+      email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
     }
     if (!currentAttentionSet.isEmpty()) {
       // We need names rather than account ids / emails to make it user readable.
-      addSoyParam(
+      email.addSoyParam(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
+          currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
     }
 
     setChangeSubjectHeader();
-    if (getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || getNotify().handling().equals(NotifyHandling.ALL)) {
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
       try {
         this.changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
         this.changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
       } catch (StorageException e) {
         throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
       }
     }
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    email.appendText(email.textTemplate("ChangeHeader"));
+    changeEmailDecorator.populateEmailContent();
+    email.appendText(email.textTemplate("ChangeFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeFooterHtml"));
+    }
   }
 
   /**
@@ -630,7 +677,7 @@
     Set<String> reviewers = new TreeSet<>();
     try {
       for (Account.Id who : changeData.reviewers().byState(state)) {
-        reviewers.add(getNameEmailFor(who));
+        reviewers.add(email.getNameEmailFor(who));
       }
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change reviewers");
@@ -705,8 +752,7 @@
    * @param sourceDiff the unified diff that we're converting to the map.
    * @return map of 'type' to a line's content.
    */
-  protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
-      String sourceDiff) {
+  public static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     for (String diffLine : lineSplitter.split(sourceDiff)) {
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
similarity index 81%
rename from java/com/google/gerrit/server/mail/send/CommentSender.java
rename to java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
index 9787884..48b2257 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
@@ -35,20 +37,19 @@
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
@@ -66,19 +67,11 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
-public class CommentSender extends ReplyToChangeSender {
+@AutoFactory
+public class CommentChangeEmailDecorator implements ChangeEmailDecorator {
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-
-    CommentSender create(
-        Project.NameKey project,
-        Change.Id changeId,
-        ObjectId preUpdateMetaId,
-        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
-  }
-
   private class FileCommentGroup {
 
     public String filename;
@@ -89,19 +82,31 @@
     /** Returns a web link to a comment for a change. */
     @Nullable
     public String getCommentLink(String uuid) {
-      return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getInlineCommentView(changeEmail.getChange(), uuid)
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /** Returns a web link to the comment tab view of a change. */
     @Nullable
     public String getCommentsTabLink() {
-      return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getCommentsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /** Returns a web link to the findings tab view of a change. */
     @Nullable
     public String getFindingsTabLink() {
-      return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getFindingsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /**
@@ -120,6 +125,9 @@
     }
   }
 
+  private EmailArguments args;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private List<? extends Comment> inlineComments = Collections.emptyList();
   @Nullable private String patchSetComment;
   private ImmutableList<LabelVote> labels = ImmutableList.of();
@@ -130,17 +138,15 @@
       preUpdateSubmitRequirementResultsSupplier;
   private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
-  @Inject
-  public CommentSender(
-      EmailArguments args,
-      CommentsUtil commentsUtil,
-      @GerritServerConfig Config cfg,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted ObjectId preUpdateMetaId,
-      @Assisted
-          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
-    super(args, "comment", newChangeData(args, project, changeId));
+  public CommentChangeEmailDecorator(
+      @Provided EmailArguments args,
+      @Provided CommentsUtil commentsUtil,
+      @Provided @GerritServerConfig Config cfg,
+      Project.NameKey project,
+      Change.Id changeId,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    this.args = args;
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
@@ -151,7 +157,7 @@
             () ->
                 // Triggers an (expensive) evaluation of the submit requirements. This is OK since
                 // all callers sent this email asynchronously, see EmailReviewComments.
-                newChangeData(args, project, changeId, preUpdateMetaId)
+                args.newChangeData(project, changeId, preUpdateMetaId)
                     .submitRequirementsIncludingLegacy());
     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
@@ -169,45 +175,31 @@
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
-
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
-    setHeader(MailHeader.COMMENT_DATE.fieldName(), timestamp);
+    email.setHeader(MailHeader.COMMENT_DATE.fieldName(), changeEmail.getTimestamp());
 
     if (incomingEmailEnabled) {
       if (replyToAddress == null) {
         // Remove Reply-To and use outbound SMTP (default) instead.
-        removeHeader(FieldName.REPLY_TO);
+        email.removeHeader(FieldName.REPLY_TO);
       } else {
-        setHeader(FieldName.REPLY_TO, replyToAddress);
+        email.setHeader(FieldName.REPLY_TO, replyToAddress);
       }
     }
-  }
-
-  @Override
-  public void formatChange() throws EmailException {
-    appendText(textTemplate("Comment"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentHtml"));
-    }
-  }
-
-  @Override
-  public void formatFooter() throws EmailException {
-    appendText(textTemplate("CommentFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
-    }
+    changeEmail.markAsReply();
   }
 
   /**
    * Returns a list of FileCommentGroup objects representing the inline comments grouped by the
    * file.
    */
-  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
-    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
+  private List<CommentChangeEmailDecorator.FileCommentGroup> getGroupedInlineComments(
+      Repository repo) {
+    List<CommentChangeEmailDecorator.FileCommentGroup> groups = new ArrayList<>();
 
     // Loop over the comments and collect them into groups based on the file
     // location of the comment.
@@ -221,7 +213,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
+        Map<String, FileDiffOutput> modifiedFiles = changeEmail.listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
@@ -232,7 +224,7 @@
                 "Cannot load %s from %s in %s",
                 c.key.filename,
                 modifiedFiles.values().iterator().next().newCommitId().name(),
-                projectState.getName());
+                changeEmail.getProjectState().getName());
             currentGroup.fileData = null;
           }
         }
@@ -317,7 +309,7 @@
     }
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
+      return commentsUtil.getPublishedHumanComment(changeEmail.getChangeData().notes(), key);
     } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
@@ -352,7 +344,7 @@
    * or the first line, or following the last period within the first 100 characters, whichever is
    * shorter. If the message is shortened, an ellipsis is appended.
    */
-  protected static String getShortenedCommentMessage(String message) {
+  static String getShortenedCommentMessage(String message) {
     int threshold = 100;
     String fullMessage = message.trim();
     String msg = fullMessage;
@@ -381,7 +373,7 @@
     return msg;
   }
 
-  protected static String getShortenedCommentMessage(Comment comment) {
+  static String getShortenedCommentMessage(Comment comment) {
     return getShortenedCommentMessage(comment.message);
   }
 
@@ -392,7 +384,7 @@
   private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
     List<Map<String, Object>> commentGroups = new ArrayList<>();
 
-    for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
+    for (CommentChangeEmailDecorator.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
@@ -503,54 +495,66 @@
   @Nullable
   private Repository getRepository() {
     try {
-      return args.server.openRepository(projectState.getNameKey());
+      return args.server.openRepository(changeEmail.getProjectState().getNameKey());
     } catch (IOException e) {
       return null;
     }
   }
 
   @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
     boolean hasComments;
     try (Repository repo = getRepository()) {
       List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
-      addSoyParam("commentFiles", files);
+      email.addSoyParam("commentFiles", files);
       hasComments = !files.isEmpty();
     }
 
-    addSoyParam(
+    email.addSoyParam(
         "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
-    addSoyParam("labels", getLabelVoteSoyData(labels));
-    addSoyParam("commentCount", inlineComments.size());
-    addSoyParam("commentTimestamp", getCommentTimestamp());
-    addSoyParam(
-        "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
+    email.addSoyParam("labels", getLabelVoteSoyData(labels));
+    email.addSoyParam("commentCount", inlineComments.size());
+    email.addSoyParam("commentTimestamp", getCommentTimestamp());
+    email.addSoyParam(
+        "coverLetterBlocks",
+        commentBlocksToSoyData(CommentFormatter.parse(changeEmail.getCoverLetter())));
 
     if (isChangeNoLongerSubmittable()) {
-      addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
-      addSoyParam(
+      email.addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      email.addSoyParam(
           "oldSubmitRequirements",
           formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
-      addSoyParam(
+      email.addSoyParam(
           "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
     }
 
-    addFooter(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
-    addFooter(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
-    addFooter(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
+    email.addFooter(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
+    email.addFooter(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
+    email.addFooter(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
 
     for (Account.Id account : getReplyAccounts()) {
-      addFooter(MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + getNameEmailFor(account));
+      email.addFooter(
+          MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + email.getNameEmailFor(account));
     }
 
-    if (getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || getNotify().handling().equals(NotifyHandling.ALL)) {
-      ccAllApprovals();
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      changeEmail.ccAllApprovals();
     }
-    if (getNotify().handling().equals(NotifyHandling.ALL)) {
-      bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
+    if (email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      changeEmail.bccStarredBy();
+      changeEmail.includeWatchers(
+          NotifyType.ALL_COMMENTS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    }
+
+    email.appendText(email.textTemplate("Comment"));
+    email.appendText(email.textTemplate("CommentFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("CommentHtml"));
+      email.appendHtml(email.soyHtmlTemplate("CommentFooterHtml"));
     }
   }
 
@@ -566,7 +570,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s before the update is %s",
-        change.getId(), isSubmittablePreUpdate);
+        changeEmail.getChange().getId(), isSubmittablePreUpdate);
     if (!isSubmittablePreUpdate) {
       return false;
     }
@@ -576,7 +580,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s after the update is %s",
-        change.getId(), isSubmittablePostUpdate);
+        changeEmail.getChange().getId(), isSubmittablePostUpdate);
     return !isSubmittablePostUpdate;
   }
 
@@ -644,6 +648,6 @@
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
     return MailProcessingUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp, ZoneId.of("UTC")));
+        ZonedDateTime.ofInstant(changeEmail.getTimestamp(), ZoneId.of("UTC")));
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
deleted file mode 100644
index 0d8bc12..0000000
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Notify interested parties of a brand new change. */
-public class CreateChangeSender extends NewChangeSender {
-  public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public CreateChangeSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    includeWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
-    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
new file mode 100644
index 0000000..b2228f5
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2019 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.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.Collections;
+import java.util.List;
+
+/** Informs a user by email about the removal of an SSH or GPG key from their account. */
+@AutoFactory
+public class DeleteKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = Collections.emptyList();
+    this.sshKey = sshKey;
+  }
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator,
+      IdentifiedUser user,
+      List<String> gpgKeyFingerprints) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = gpgKeyFingerprints;
+    this.sshKey = null;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeyFingerprints", getGpgKeyFingerprints());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("DeleteKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeyFingerprints != null) {
+      return "GPG";
+    }
+    throw new IllegalStateException("key type is not SSH or GPG");
+  }
+
+  @Nullable
+  private String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  @Nullable
+  private String getGpgKeyFingerprints() {
+    if (!gpgKeyFingerprints.isEmpty()) {
+      return Joiner.on("\n").join(gpgKeyFingerprints);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
deleted file mode 100644
index e9a2ead..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2019 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Sender that informs a user by email about the removal of an SSH or GPG key from their account.
- */
-public class DeleteKeySender extends OutgoingEmail {
-  public interface Factory {
-    DeleteKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    DeleteKeySender create(IdentifiedUser user, List<String> gpgKeyFingerprints);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeyFingerprints;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = Collections.emptyList();
-    this.sshKey = sshKey;
-  }
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeyFingerprints) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = gpgKeyFingerprints;
-    this.sshKey = null;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("DeleteKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("email", getEmail());
-    addSoyEmailDataParam("gpgKeyFingerprints", getGpgKeyFingerprints());
-    addSoyEmailDataParam("keyType", getKeyType());
-    addSoyEmailDataParam("sshKey", getSshKey());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeyFingerprints != null) {
-      return "GPG";
-    }
-    throw new IllegalStateException("key type is not SSH or GPG");
-  }
-
-  @Nullable
-  private String getSshKey() {
-    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
-  }
-
-  @Nullable
-  private String getGpgKeyFingerprints() {
-    if (!gpgKeyFingerprints.isEmpty()) {
-      return Joiner.on("\n").join(gpgKeyFingerprints);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
new file mode 100644
index 0000000..5b8ac5d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Let users know that a reviewer and possibly her review have been removed. */
+public class DeleteReviewerChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  @Nullable
+  private List<String> getReviewerNames() {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.ccExistingReviewers();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+    reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r));
+    reviewersByEmail.stream().forEach(address -> email.addByEmail(RecipientType.TO, address));
+
+    email.appendText(email.textTemplate("DeleteReviewer"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteReviewerHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
deleted file mode 100644
index 8ebc15d..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Let users know that a reviewer and possibly her review have been removed. */
-public class DeleteReviewerSender extends ReplyToChangeSender {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-
-  public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
-    @Override
-    DeleteReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public DeleteReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "deleteReviewer", newChangeData(args, project, changeId));
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("DeleteReviewer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteReviewerHtml"));
-    }
-  }
-
-  @Nullable
-  public List<String> getReviewerNames() {
-    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address a : reviewersByEmail) {
-      names.add(a.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("reviewerNames", getReviewerNames());
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
-    reviewersByEmail.stream().forEach(address -> addByEmail(RecipientType.TO, address));
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
new file mode 100644
index 0000000..873db91
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a vote that was removed from a change. */
+public class DeleteVoteChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("DeleteVote"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteVoteHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
deleted file mode 100644
index cc37325c..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a vote that was removed from a change. */
-public class DeleteVoteSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<DeleteVoteSender> {
-    @Override
-    DeleteVoteSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  protected DeleteVoteSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "deleteVote", newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("DeleteVote"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 96effc1..f77b2c4 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AnonymousUser;
@@ -49,18 +52,21 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
  * Arguments used for sending notification emails.
  *
- * <p>Notification emails are sent by out by {@link OutgoingEmail} and it's subclasses, so called
- * senders. To construct an email the sender class needs to get various other classes injected.
- * Instead of injecting these classes into the sender classes directly, they only get {@code
- * EmailArguments} injected and {@code EmailArguments} provides them all dependencies that they
- * need.
+ * <p>Notification emails are sent by out by {@link OutgoingEmail} . To construct an email class (or
+ * its decorators) needs to get various other classes injected. Instead of injecting these classes
+ * into the sender classes directly, they only get {@code EmailArguments} injected and {@code
+ * EmailArguments} provides them all dependencies that they need.
  *
  * <p>This class is public because plugins need access to it for sending emails.
  */
@@ -164,4 +170,24 @@
     this.currentUserProvider = currentUserProvider;
     this.retryHelper = retryHelper;
   }
+
+  /** Fetch ChangeData for the specified change. */
+  public ChangeData newChangeData(Project.NameKey project, Change.Id id) {
+    return changeDataFactory.create(project, id);
+  }
+
+  /** Fetch ChangeData for specified change and revision. */
+  public ChangeData newChangeData(Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return changeDataFactory.create(changeNotesFactory.createChecked(project, id, metaId));
+  }
+
+  @Nullable
+  public static String addUspParam(String url) {
+    try {
+      URI uri = new URIBuilder(url).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
new file mode 100644
index 0000000..af265a6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2019 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.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that the HTTP password of their account was updated. */
+@AutoFactory
+public class HttpPasswordUpdateEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public HttpPasswordUpdateEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, String operation) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.operation = operation;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    email.setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("operation", operation);
+    email.addSoyEmailDataParam("httpPasswordSettingsUrl", email.getSettingsUrl("http-password"));
+
+    email.appendText(email.textTemplate("HttpPasswordUpdate"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("HttpPasswordUpdateHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
deleted file mode 100644
index 0564355..0000000
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2019 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-/** Sender that informs a user by email that the HTTP password of their account was updated. */
-public class HttpPasswordUpdateSender extends OutgoingEmail {
-  public interface Factory {
-    HttpPasswordUpdateSender create(IdentifiedUser user, String operation);
-  }
-
-  private final IdentifiedUser user;
-  private final String operation;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public HttpPasswordUpdateSender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted String operation) {
-    super(args, "HttpPasswordUpdate");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.operation = operation;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
-    setMessageId(
-        messageIdGenerator.fromReasonAccountIdAndTimestamp(
-            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    // Always send an email if the HTTP password is updated.
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("HttpPasswordUpdate"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HttpPasswordUpdateHtml"));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("email", getEmail());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-    addSoyEmailDataParam("operation", operation);
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
new file mode 100644
index 0000000..1f8fd78
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import org.apache.james.mime4j.dom.field.FieldName;
+
+/** Send an email to inform users that parsing their inbound email failed. */
+public class InboundEmailRejectionEmailDecorator implements EmailDecorator {
+
+  /** Used by the templating system to determine what error message should be sent */
+  public enum InboundEmailError {
+    PARSING_ERROR,
+    INACTIVE_ACCOUNT,
+    UNKNOWN_ACCOUNT,
+    INTERNAL_EXCEPTION,
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
+  }
+
+  private OutgoingEmail email;
+  private final Address to;
+  private final InboundEmailError reason;
+  private final String threadId;
+
+  public InboundEmailRejectionEmailDecorator(
+      Address to, String threadId, InboundEmailError reason) {
+    this.to = requireNonNull(to);
+    this.threadId = requireNonNull(threadId);
+    this.reason = requireNonNull(reason);
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    setListIdHeader();
+    email.setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
+
+    if (!threadId.isEmpty()) {
+      email.setHeader(MailHeader.REFERENCES.fieldName(), threadId);
+    }
+  }
+
+  private void setListIdHeader() {
+    // Set a reasonable list id so that filters can be used to sort messages
+    email.setHeader("List-Id", "<gerrit-noreply." + email.getGerritHost() + ">");
+    if (email.getSettingsUrl() != null) {
+      email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
+    }
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addByEmail(RecipientType.TO, to);
+
+    email.appendText(email.textTemplate("InboundEmailRejection_" + reason.name()));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
deleted file mode 100644
index 3a91fe6..0000000
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.MailHeader;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.apache.james.mime4j.dom.field.FieldName;
-
-/** Send an email to inform users that parsing their inbound email failed. */
-public class InboundEmailRejectionSender extends OutgoingEmail {
-
-  /** Used by the templating system to determine what error message should be sent */
-  public enum InboundEmailError {
-    PARSING_ERROR,
-    INACTIVE_ACCOUNT,
-    UNKNOWN_ACCOUNT,
-    INTERNAL_EXCEPTION,
-    COMMENT_REJECTED,
-    CHANGE_NOT_FOUND
-  }
-
-  public interface Factory {
-    InboundEmailRejectionSender create(Address to, String threadId, InboundEmailError reason);
-  }
-
-  private final Address to;
-  private final InboundEmailError reason;
-  private final String threadId;
-
-  @Inject
-  public InboundEmailRejectionSender(
-      EmailArguments args,
-      @Assisted Address to,
-      @Assisted String threadId,
-      @Assisted InboundEmailError reason) {
-    super(args, "error");
-    this.to = requireNonNull(to);
-    this.threadId = requireNonNull(threadId);
-    this.reason = requireNonNull(reason);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-    setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
-
-    if (!threadId.isEmpty()) {
-      setHeader(MailHeader.REFERENCES.fieldName(), threadId);
-    }
-  }
-
-  private void setListIdHeader() {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setHeader("List-Id", "<gerrit-noreply." + getGerritHost() + ">");
-    if (getSettingsUrl() != null) {
-      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
-    }
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("InboundEmailRejection_" + reason.name()));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addByEmail(RecipientType.TO, to);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 0eaafb8..7bc319f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -65,6 +65,8 @@
     "DeleteReviewerHtml.soy",
     "DeleteVote.soy",
     "DeleteVoteHtml.soy",
+    "Email.soy",
+    "EmailHtml.soy",
     "InboundEmailRejection.soy",
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
similarity index 66%
rename from java/com/google/gerrit/server/mail/send/MergedSender.java
rename to java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
index 29191cc..90c8b93 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
@@ -14,70 +14,57 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
-public class MergedSender extends ReplyToChangeSender {
+@AutoFactory
+public class MergedChangeEmailDecorator implements ChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    MergedSender create(
-        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
-  }
-
-  private final LabelTypes labelTypes;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+  private LabelTypes labelTypes;
+  private final EmailArguments args;
   private final Optional<String> stickyApprovalDiff;
 
-  @Inject
-  public MergedSender(
-      EmailArguments args,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted Optional<String> stickyApprovalDiff) {
-    super(args, "merged", newChangeData(args, project, changeId));
-    labelTypes = changeData.getLabelTypes();
+  MergedChangeEmailDecorator(@Provided EmailArguments args, Optional<String> stickyApprovalDiff) {
+    this.args = args;
     this.stickyApprovalDiff = stickyApprovalDiff;
-    // We want to send the submit email even if the "send only when in attention set" is enabled.
-    emailOnlyAttentionSetIfEnabled = false;
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+    labelTypes = changeEmail.getChangeData().getLabelTypes();
 
-    NotifyResolver.Result notify = getNotify();
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    changeEmail.setEmailOnlyAttentionSetIfEnabled(false);
+
+    NotifyResolver.Result notify = email.getNotify();
     if (!stickyApprovalDiff.isEmpty() && !notify.handling().equals(NotifyHandling.ALL)) {
       logger.atFine().log(
           "Requested to notify %s, but for change submission with sticky approval diff,"
               + " Notify=ALL is enforced.",
           notify.handling().name());
-      setNotify(NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts()));
-    }
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Merged"));
-
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("MergedHtml"));
+      email.setNotify(NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts()));
     }
   }
 
@@ -85,7 +72,9 @@
     try {
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
+      for (PatchSetApproval ca :
+          args.approvalsUtil.byPatchSet(
+              changeEmail.getChangeData().notes(), changeEmail.getPatchSet().id())) {
         Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
         if (!lt.isPresent()) {
           continue;
@@ -112,7 +101,7 @@
     txt.append(type).append(":\n");
     for (Account.Id id : approvals.rowKeySet()) {
       txt.append("  ");
-      txt.append(getNameFor(id));
+      txt.append(email.getNameFor(id));
       txt.append(": ");
       boolean first = true;
       for (LabelType lt : labelTypes.getLabelTypes()) {
@@ -143,17 +132,24 @@
   }
 
   @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("approvals", getApprovals());
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("approvals", getApprovals());
     if (stickyApprovalDiff.isPresent()) {
-      addSoyEmailDataParam("stickyApprovalDiff", stickyApprovalDiff.get());
-      addSoyEmailDataParam("stickyApprovalDiffHtml", getDiffTemplateData(stickyApprovalDiff.get()));
+      email.addSoyEmailDataParam("stickyApprovalDiff", stickyApprovalDiff.get());
+      email.addSoyEmailDataParam(
+          "stickyApprovalDiffHtml", ChangeEmail.getDiffTemplateData(stickyApprovalDiff.get()));
     }
 
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    includeWatchers(NotifyType.SUBMITTED_CHANGES);
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+    changeEmail.includeWatchers(NotifyType.SUBMITTED_CHANGES);
+
+    email.appendText(email.textTemplate("Merged"));
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("MergedHtml"));
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
deleted file mode 100644
index c073805..0000000
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2009 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Asks a user to review a change. */
-public class ModifyReviewerSender extends NewChangeSender {
-  public interface Factory {
-    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public ModifyReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    ccExistingReviewers();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
deleted file mode 100644
index dd7c1fe..0000000
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2009 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Sends an email alerting a user to a new change for them to review. */
-public abstract class NewChangeSender extends ChangeEmail {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-  private final Set<Address> extraCCByEmail = new HashSet<>();
-  private final Set<Account.Id> removedReviewers = new HashSet<>();
-  private final Set<Address> removedByEmailReviewers = new HashSet<>();
-
-  protected NewChangeSender(EmailArguments args, ChangeData changeData) {
-    super(args, "newchange", changeData);
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  public void addExtraCC(Collection<Account.Id> cc) {
-    extraCC.addAll(cc);
-  }
-
-  public void addExtraCCByEmail(Collection<Address> cc) {
-    extraCCByEmail.addAll(cc);
-  }
-
-  public void addRemovedReviewers(Collection<Account.Id> removed) {
-    removedReviewers.addAll(removed);
-  }
-
-  public void addRemovedByEmailReviewers(Collection<Address> removed) {
-    removedByEmailReviewers.addAll(removed);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    String threadId = getChangeMessageThreadId();
-    setHeader("References", threadId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("NewChange"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("NewChangeHtml"));
-    }
-  }
-
-  @Nullable
-  private List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-
-  @Nullable
-  private List<String> getRemovedReviewerNames() {
-    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : removedReviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address address : removedByEmailReviewers) {
-      names.add(address.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyParam("ownerName", getNameFor(change.getOwner()));
-    addSoyEmailDataParam("reviewerNames", getReviewerNames());
-    addSoyEmailDataParam("removedReviewerNames", getRemovedReviewerNames());
-
-    switch (getNotify().handling()) {
-      case NONE:
-      case OWNER:
-        break;
-      case ALL:
-      default:
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
-        extraCCByEmail.stream().forEach(cc -> addByEmail(RecipientType.CC, cc));
-        // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        reviewersByEmail.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        removedReviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        removedByEmailReviewers.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        break;
-    }
-    addAuthors(RecipientType.CC);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index d432ab8..ca19150 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -39,6 +41,8 @@
 import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
+import com.google.template.soy.data.SanitizedContent.ContentKind;
+import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -57,8 +61,49 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
-/** Sends an email to one or more interested parties. */
-public abstract class OutgoingEmail {
+/** Represents an email notification for some event that can be sent to interested parties. */
+@AutoFactory
+public final class OutgoingEmail {
+
+  /** Provides content, recipients and any customizations of the email. */
+  public interface EmailDecorator {
+    /**
+     * Stores the reference to the email for the subsequent calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * OutgoingEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() throws EmailException {
+      return true;
+    }
+
+    /** Evaluates whether account can be added to the list of recipients. */
+    default boolean isRecipientAllowed(Account.Id rcpt) throws PermissionBackendException {
+      return true;
+    }
+
+    /** Evaluates whether email can be added to the list of recipients. */
+    default boolean isRecipientAllowed(Address rcpt) throws PermissionBackendException {
+      return true;
+    }
+  }
+
   private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -74,19 +119,24 @@
   private Map<String, Object> soyContext;
   private Map<String, Object> soyContextEmailData;
   private List<String> footers;
-  protected final EmailArguments args;
+  private final EmailArguments args;
   private Account.Id fromId;
   private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  private final EmailDecorator templateProvider;
 
-  protected OutgoingEmail(EmailArguments args, String messageClass) {
+  public OutgoingEmail(
+      @Provided EmailArguments args, String messageClass, EmailDecorator templateProvider) {
     this.args = args;
     this.messageClass = messageClass;
+    this.templateProvider = templateProvider;
   }
 
+  /** Specify the account that triggered the notification. */
   public void setFrom(Account.Id id) {
     fromId = id;
   }
 
+  /** Get the account that triggered the notification. */
   public Account.Id getFrom() {
     return fromId;
   }
@@ -101,10 +151,27 @@
     return this.notify;
   }
 
+  /** Set identifier for the email. Every email must have one. */
   public void setMessageId(MessageIdGenerator.MessageId messageId) {
     this.messageId = messageId;
   }
 
+  private String constructTextEmail() {
+    soyContext.put("body", textBody.toString());
+    soyContext.put("footer", textTemplate("Footer"));
+    return textTemplate("Email");
+  }
+
+  private String constructHtmlEmail() {
+    soyContext.put(
+        "body", UnsafeSanitizedContentOrdainer.ordainAsSafe(htmlBody.toString(), ContentKind.HTML));
+    soyContext.put(
+        "footer",
+        UnsafeSanitizedContentOrdainer.ordainAsSafe(
+            soyHtmlTemplate("FooterHtml"), ContentKind.HTML));
+    return soyHtmlTemplate("EmailHtml");
+  }
+
   /** Format and enqueue the message for delivery. */
   public void send() throws EmailException {
     try {
@@ -143,11 +210,6 @@
     if (messageId == null) {
       throw new IllegalStateException("All emails must have a messageId");
     }
-    format();
-    appendText(textTemplate("Footer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("FooterHtml"));
-    }
 
     Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
     if (shouldSendMessage()) {
@@ -247,16 +309,15 @@
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
-      String textPart = textBody.toString();
       OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
       va.messageClass = messageClass;
       va.smtpFromAddress = smtpFromAddress;
       va.smtpRcptTo = smtpRcptTo;
       va.headers = headers;
-      va.body = textPart;
+      va.body = constructTextEmail();
 
       if (useHtml()) {
-        va.htmlBody = htmlBody.toString();
+        va.htmlBody = constructHtmlEmail();
       } else {
         va.htmlBody = null;
       }
@@ -284,7 +345,7 @@
         shallowCopy.remove(FieldName.CC);
         for (Address a : smtpRcptToPlaintextOnly) {
           // Add new To
-          EmailHeader.AddressList to = new EmailHeader.AddressList();
+          AddressList to = new AddressList();
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
@@ -320,24 +381,21 @@
     }
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void format() throws EmailException;
-
   /**
    * Setup the message headers and envelope (TO, CC, BCC).
    *
    * @throws EmailException if an error occurred.
    */
-  protected void init() throws EmailException {
+  public void init() throws EmailException {
     soyContext = new HashMap<>();
     footers = new ArrayList<>();
     soyContextEmailData = new HashMap<>();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
     setHeader(FieldName.DATE, Instant.now());
-    headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
-    headers.put(FieldName.TO, new EmailHeader.AddressList());
-    headers.put(FieldName.CC, new EmailHeader.AddressList());
+    headers.put(FieldName.FROM, new AddressList(smtpFromAddress));
+    headers.put(FieldName.TO, new AddressList());
+    headers.put(FieldName.CC, new AddressList());
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -348,9 +406,11 @@
     if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
       appendText(getFromLine());
     }
+
+    templateProvider.init(this);
   }
 
-  protected String getFromLine() {
+  private String getFromLine() {
     StringBuilder f = new StringBuilder();
     Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account);
     if (account.isPresent()) {
@@ -371,9 +431,10 @@
   }
 
   public String getGerritHost() {
-    if (getGerritUrl() != null) {
+    Optional<String> gerritUrl = args.urlFormatter.get().getWebUrl();
+    if (gerritUrl.isPresent()) {
       try {
-        return new URL(getGerritUrl()).getHost();
+        return new URL(gerritUrl.get()).getHost();
       } catch (MalformedURLException e) {
         // Try something else.
       }
@@ -388,44 +449,49 @@
 
   @Nullable
   public String getSettingsUrl() {
-    return args.urlFormatter.get().getSettingsUrl().orElse(null);
+    return args.urlFormatter.get().getSettingsUrl().map(EmailArguments::addUspParam).orElse(null);
   }
 
   @Nullable
-  private String getGerritUrl() {
-    return args.urlFormatter.get().getWebUrl().orElse(null);
+  public String getSettingsUrl(String section) {
+    return args.urlFormatter
+        .get()
+        .getSettingsUrl(section)
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
   /** Set a header in the outgoing message. */
-  protected void setHeader(String name, String value) {
+  public void setHeader(String name, String value) {
     headers.put(name, new StringEmailHeader(value));
   }
 
   /** Remove a header from the outgoing message. */
-  protected void removeHeader(String name) {
+  public void removeHeader(String name) {
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Instant date) {
+  /** Set a date header in the outgoing message. */
+  public void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
   /** Append text to the outgoing email body. */
-  protected void appendText(String text) {
+  public void appendText(String text) {
     if (text != null) {
       textBody.append(text);
     }
   }
 
   /** Append html to the outgoing email body. */
-  protected void appendHtml(String html) {
+  public void appendHtml(String html) {
     if (html != null) {
       htmlBody.append(html);
     }
   }
 
   /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(@Nullable Account.Id accountId) {
+  public String getNameFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return args.gerritPersonIdent.get().getName();
     }
@@ -451,7 +517,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  protected String getNameEmailFor(@Nullable Account.Id accountId) {
+  public String getNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       PersonIdent gerritIdent = args.gerritPersonIdent.get();
       return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">";
@@ -480,7 +546,7 @@
    * @return name/email of account, username, or null if unset or the accountId is null.
    */
   @Nullable
-  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+  public String getUserNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return null;
     }
@@ -503,7 +569,7 @@
     return accountState.get().userName().orElse(null);
   }
 
-  protected boolean shouldSendMessage() {
+  private boolean shouldSendMessage() throws EmailException {
     if (textBody.length() == 0) {
       // If we have no message body, don't send.
       logger.atFine().log("Not sending '%s': No message body", messageClass);
@@ -528,7 +594,7 @@
       return false;
     }
 
-    return true;
+    return templateProvider.shouldSendMessage();
   }
 
   /**
@@ -566,8 +632,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(addr);
   }
 
   /**
@@ -576,7 +642,7 @@
    * @param rt category of recipient (TO, CC, BCC)
    * @param to Gerrit Account of the recipient.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to) {
+  public void addByAccountId(RecipientType rt, Account.Id to) {
     addByAccountId(rt, to, false);
   }
 
@@ -588,7 +654,7 @@
    * @param override if the recipient was added previously and override is false no change is made
    *     regardless of {@code rt}.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
+  public void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
     try {
       if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
         rcptTo.add(to);
@@ -606,8 +672,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(to);
   }
 
   private final void add(RecipientType rt, Address addr, boolean override) {
@@ -619,16 +685,16 @@
           if (!override) {
             return;
           }
-          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
-          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.TO)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.CC)).remove(addr.email());
           smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
-            ((EmailHeader.AddressList) headers.get(FieldName.TO)).add(addr);
+            ((AddressList) headers.get(FieldName.TO)).add(addr);
             break;
           case CC:
-            ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
+            ((AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
             smtpBccRcptTo.add(addr);
@@ -653,14 +719,8 @@
     return Address.create(account.fullName(), e);
   }
 
-  /**
-   * Populate the email content.
-   *
-   * <p>Subclasses may override this method to populate further email content.
-   *
-   * @throws EmailException thrown if there is an error while populating the email content
-   */
-  protected void populateEmailContent() throws EmailException {
+  /** Set recipients, headers, body of the email. */
+  public void populateEmailContent() throws EmailException {
     for (RecipientType recipientType : notify.accounts().keySet()) {
       notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
     }
@@ -670,8 +730,9 @@
     addSoyEmailDataParam("settingsUrl", getSettingsUrl());
     addSoyEmailDataParam("instanceName", getInstanceName());
     addSoyEmailDataParam("gerritHost", getGerritHost());
-    addSoyEmailDataParam("gerritUrl", getGerritUrl());
     addSoyParam("email", soyContextEmailData);
+
+    templateProvider.populateEmailContent();
   }
 
   /** Adds param to the data map passed into soy when rendering templates. */
@@ -697,12 +758,12 @@
   }
 
   /** Renders a soy template of kind="text". */
-  protected String textTemplate(String name) {
+  public String textTemplate(String name) {
     return configureRenderer(name).renderText().get();
   }
 
   /** Renders a soy template of kind="html". */
-  protected String soyHtmlTemplate(String name) {
+  public String soyHtmlTemplate(String name) {
     return configureRenderer(name).renderHtml().get().toString();
   }
 
@@ -726,7 +787,8 @@
     return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
   }
 
-  protected void removeUser(Account user) {
+  /** Remove user from the multipart email recipients. */
+  private void removeUser(Account user) {
     String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
       if (j.next().email().equals(fromEmail)) {
@@ -741,7 +803,8 @@
     }
   }
 
-  protected final boolean useHtml() {
+  /** Return true, if the email should include html body. */
+  public boolean useHtml() {
     return args.settings.html;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
new file mode 100644
index 0000000..c82a016
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2009 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.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+
+/**
+ * Sender that informs a user by email about the registration of a new email address for their
+ * account.
+ */
+@AutoFactory
+public class RegisterNewEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+  private final EmailArguments args;
+  private final EmailTokenVerifier tokenVerifier;
+  private final IdentifiedUser user;
+  private final String addr;
+  private String emailToken;
+
+  RegisterNewEmailDecorator(
+      @Provided EmailArguments args,
+      @Provided EmailTokenVerifier tokenVerifier,
+      @Provided IdentifiedUser callingUser,
+      final String address) {
+    this.args = args;
+    this.tokenVerifier = tokenVerifier;
+    this.user = callingUser;
+    this.addr = address;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] Email Verification");
+    email.addByEmail(RecipientType.TO, Address.create(addr));
+  }
+
+  public boolean isAllowed() {
+    return args.emailSender.canEmail(addr);
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("emailRegistrationLink", getEmailRegistrationLink());
+
+    email.appendText(email.textTemplate("RegisterNewEmail"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RegisterNewEmailHtml"));
+    }
+  }
+
+  private String getEmailRegistrationLink() {
+    if (emailToken == null) {
+      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+    }
+    return args.urlFormatter.get().getWebUrl().orElse("") + "#/VE/" + emailToken;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
deleted file mode 100644
index 73c5fb5..0000000
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 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.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.EmailTokenVerifier;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * Sender that informs a user by email about the registration of a new email address for their
- * account.
- */
-public class RegisterNewEmailSender extends OutgoingEmail {
-  public interface Factory {
-    RegisterNewEmailSender create(String address);
-  }
-
-  private final EmailTokenVerifier tokenVerifier;
-  private final IdentifiedUser user;
-  private final String addr;
-  private String emailToken;
-
-  @Inject
-  public RegisterNewEmailSender(
-      EmailArguments args,
-      EmailTokenVerifier tokenVerifier,
-      IdentifiedUser callingUser,
-      @Assisted final String address) {
-    super(args, "registernewemail");
-    this.tokenVerifier = tokenVerifier;
-    this.user = callingUser;
-    this.addr = address;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    addByEmail(RecipientType.TO, Address.create(addr));
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("RegisterNewEmail"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RegisterNewEmailHtml"));
-    }
-  }
-
-  public boolean isAllowed() {
-    return args.emailSender.canEmail(addr);
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("emailRegistrationToken", getEmailRegistrationToken());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmailRegistrationToken() {
-    if (emailToken == null) {
-      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
-    }
-    return emailToken;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
deleted file mode 100644
index 5242bfb..0000000
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Let users know of a user removed from the attention set. */
-public class RemoveFromAttentionSetSender extends AttentionSetSender {
-
-  public interface Factory extends ReplyToChangeSender.Factory<RemoveFromAttentionSetSender> {
-    @Override
-    RemoveFromAttentionSetSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RemoveFromAttentionSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "removeFromAttentionSet", project, changeId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("RemoveFromAttentionSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RemoveFromAttentionSetHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
similarity index 72%
rename from java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
rename to java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
index 2307ad8..a73933c 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
@@ -28,13 +30,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -44,18 +44,13 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
-public class ReplacePatchSetSender extends ReplyToChangeSender {
+@AutoFactory
+public class ReplacePatchSetChangeEmailDecorator implements ChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    ReplacePatchSetSender create(
-        Project.NameKey project,
-        Change.Id changeId,
-        ChangeKind changeKind,
-        ObjectId preUpdateMetaId,
-        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
-  }
-
+  private final EmailArguments args;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final ChangeKind changeKind;
@@ -64,16 +59,14 @@
       preUpdateSubmitRequirementResultsSupplier;
   private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
-  @Inject
-  public ReplacePatchSetSender(
-      EmailArguments args,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted ChangeKind changeKind,
-      @Assisted ObjectId preUpdateMetaId,
-      @Assisted
-          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
-    super(args, "newpatchset", newChangeData(args, project, changeId));
+  ReplacePatchSetChangeEmailDecorator(
+      @Provided EmailArguments args,
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeKind changeKind,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    this.args = args;
     this.changeKind = changeKind;
 
     this.preUpdateSubmitRequirementResultsSupplier =
@@ -81,22 +74,21 @@
             () ->
                 // Triggers an (expensive) evaluation of the submit requirements. This is OK since
                 // all callers sent this email asynchronously, see EmailNewPatchSet.
-                newChangeData(args, project, changeId, preUpdateMetaId)
+                args.newChangeData(project, changeId, preUpdateMetaId)
                     .submitRequirementsIncludingLegacy());
 
     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   @Override
-  protected boolean shouldSendMessage() {
+  public boolean shouldSendMessage() {
     if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
       logger.atFine().log(
           "skip email because new patch set is a trivial rebase that didn't make the change"
               + " non-submittable");
       return false;
     }
-
-    return super.shouldSendMessage();
+    return true;
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -114,10 +106,12 @@
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
 
-    Account.Id fromId = getFrom();
+    Account.Id fromId = email.getFrom();
     if (fromId != null) {
       // Don't call yourself a reviewer of your own patch set.
       //
@@ -125,22 +119,14 @@
     }
   }
 
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("ReplacePatchSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ReplacePatchSetHtml"));
-    }
-  }
-
   @Nullable
-  public ImmutableList<String> getReviewerNames() {
+  private ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
-      if (id.equals(getFrom())) {
+      if (id.equals(email.getFrom())) {
         continue;
       }
-      names.add(getNameFor(id));
+      names.add(email.getNameFor(id));
     }
     if (names.isEmpty()) {
       return null;
@@ -155,36 +141,43 @@
                 String.format(
                     "%s by %s",
                     LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
-                    getNameFor(outdatedApproval.accountId())))
+                    email.getNameFor(outdatedApproval.accountId())))
         .sorted()
         .collect(toImmutableList());
   }
 
   @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("reviewerNames", getReviewerNames());
-    addSoyEmailDataParam("outdatedApprovals", formatOutdatedApprovals());
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("outdatedApprovals", formatOutdatedApprovals());
 
     if (isChangeNoLongerSubmittable()) {
-      addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
-      addSoyParam(
+      email.addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      email.addSoyParam(
           "oldSubmitRequirements",
           formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
-      addSoyParam(
+      email.addSoyParam(
           "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
     }
 
     if (args.settings.sendNewPatchsetEmails) {
-      if (getNotify().handling().equals(NotifyHandling.ALL)
-          || getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
+      if (email.getNotify().handling().equals(NotifyHandling.ALL)
+          || email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
       }
-      addAuthors(RecipientType.CC);
     }
-    bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(
+        NotifyType.NEW_PATCHSETS,
+        !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+
+    email.appendText(email.textTemplate("ReplacePatchSet"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ReplacePatchSetHtml"));
+    }
   }
 
   /**
@@ -199,7 +192,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s before the update is %s",
-        change.getId(), isSubmittablePreUpdate);
+        changeEmail.getChange().getId(), isSubmittablePreUpdate);
     if (!isSubmittablePreUpdate) {
       return false;
     }
@@ -209,7 +202,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s after the update is %s",
-        change.getId(), isSubmittablePostUpdate);
+        changeEmail.getChange().getId(), isSubmittablePostUpdate);
     return !isSubmittablePostUpdate;
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
deleted file mode 100644
index 1f7b6d5..0000000
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/** Alert a user to a reply to a change, usually commentary made during review. */
-public abstract class ReplyToChangeSender extends ChangeEmail {
-  public interface Factory<T extends ReplyToChangeSender> {
-    T create(Project.NameKey project, Change.Id id);
-  }
-
-  protected ReplyToChangeSender(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass, changeData);
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    final String threadId = getChangeMessageThreadId();
-    setHeader("In-Reply-To", threadId);
-    setHeader("References", threadId);
-
-    addAuthors(RecipientType.TO);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
new file mode 100644
index 0000000..38eab48
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 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.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a change being restored by its owner. */
+public class RestoredChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("Restored"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RestoredHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
deleted file mode 100644
index 672b758..0000000
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2011 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being restored by its owner. */
-public class RestoredSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<RestoredSender> {
-    @Override
-    RestoredSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RestoredSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "restore", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Restored"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RestoredHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
new file mode 100644
index 0000000..d1cff9c
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 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.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a change being reverted. */
+public class RevertedChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("Reverted"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RevertedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
deleted file mode 100644
index 6ab44a7..0000000
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2011 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.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being reverted. */
-public class RevertedSender extends ReplyToChangeSender {
-  public interface Factory {
-    RevertedSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RevertedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "revert", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Reverted"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RevertedHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
new file mode 100644
index 0000000..6ae1c6a
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2009 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.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Sends an email alerting a user to a new change for them to review. */
+public class StartReviewChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
+  private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
+  private final Set<Account.Id> removedReviewers = new HashSet<>();
+  private final Set<Address> removedByEmailReviewers = new HashSet<>();
+  private boolean isCreateChange = false;
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  public void addExtraCC(Collection<Account.Id> cc) {
+    extraCC.addAll(cc);
+  }
+
+  public void addExtraCCByEmail(Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
+  public void markAsCreateChange() {
+    isCreateChange = true;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+  }
+
+  @Nullable
+  private List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    return names;
+  }
+
+  @Nullable
+  private List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("ownerName", email.getNameFor(changeEmail.getChange().getOwner()));
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("removedReviewerNames", getRemovedReviewerNames());
+
+    switch (email.getNotify().handling()) {
+      case NONE:
+      case OWNER:
+        break;
+      case ALL:
+      default:
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
+        extraCCByEmail.stream().forEach(cc -> email.addByEmail(RecipientType.CC, cc));
+        // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        reviewersByEmail.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        removedReviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        removedByEmailReviewers.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        break;
+    }
+    changeEmail.addAuthors(RecipientType.CC);
+
+    if (isCreateChange) {
+      changeEmail.includeWatchers(
+          NotifyType.NEW_CHANGES,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+      changeEmail.includeWatchers(
+          NotifyType.NEW_PATCHSETS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    } else {
+      changeEmail.ccExistingReviewers();
+    }
+
+    email.appendText(email.textTemplate("NewChange"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("NewChangeHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
index 3be55ea..eb1f692 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -23,6 +23,7 @@
   public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
   public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
   public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_CUSTOM_KEYED_VALUE = new FooterKey("Custom-Keyed-Value");
   public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
   public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
   public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index c75fd29..4a31e23 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -394,6 +394,7 @@
 
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
+  private ImmutableSortedMap<String, String> customKeyedValues;
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
   private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
@@ -477,6 +478,16 @@
     return state.allAttentionSetUpdates();
   }
 
+  /** Returns the key-value pairs that are attached to this change */
+  public ImmutableSortedMap<String, String> getCustomKeyedValues() {
+    if (customKeyedValues == null) {
+      ImmutableSortedMap.Builder<String, String> b = ImmutableSortedMap.naturalOrder();
+      b.putAll(state.customKeyedValues());
+      customKeyedValues = b.build();
+    }
+    return customKeyedValues;
+  }
+
   /**
    * Returns the evaluated submit requirements for the change. We only intend to store submit
    * requirements in NoteDb for closed changes. For closed changes, the results represent the state
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 2d3902c..37729b8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -62,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(5)
+            .version(6)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 951a478..95e528b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
@@ -47,6 +48,7 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -98,6 +100,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -166,6 +169,7 @@
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
   private final List<PatchSet.Id> currentPatchSets;
+  private final TreeMap<String, String> customKeyedValues;
   private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
   private final List<PatchSetApproval.Builder> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
@@ -234,6 +238,7 @@
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
     currentPatchSets = new ArrayList<>();
+    customKeyedValues = new TreeMap<>();
   }
 
   ChangeNotesState parseAll() throws ConfigInvalidException, IOException {
@@ -264,6 +269,7 @@
       checkMandatoryFooters();
     }
 
+    pruneEmptyCustomKeyedValues();
     return buildState();
   }
 
@@ -288,6 +294,7 @@
         submissionId,
         status,
         firstNonNull(hashtags, ImmutableSet.of()),
+        ImmutableSortedMap.copyOfSorted(customKeyedValues),
         buildPatchSets(),
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
@@ -491,6 +498,7 @@
     }
 
     parseHashtags(commit);
+    parseCustomKeyedValues(commit);
     parseAttentionSetUpdates(commit);
 
     parseSubmission(commit, commitTimestamp);
@@ -722,6 +730,30 @@
     }
   }
 
+  private void parseCustomKeyedValues(ChangeNotesCommit commit) throws ConfigInvalidException {
+    for (String customKeyedValueLine : commit.getFooterLineValues(FOOTER_CUSTOM_KEYED_VALUE)) {
+      String[] parts = customKeyedValueLine.split("=", 2);
+      String key = parts[0];
+      String value = parts[1];
+      // Commits are parsed in reverse order and only the last set of values
+      // should be used.  An empty value for a key means it's a deletion.
+      customKeyedValues.putIfAbsent(key, value);
+    }
+  }
+
+  private void pruneEmptyCustomKeyedValues() {
+    List<String> toRemove = new ArrayList<>();
+    for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+      if (entry.getValue().length() == 0) {
+        toRemove.add(entry.getKey());
+      }
+    }
+
+    for (String key : toRemove) {
+      customKeyedValues.remove(key);
+    }
+  }
+
   private void parseAttentionSetUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
     for (String attentionString : attentionStrings) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 1715b43..304b54d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
@@ -111,6 +112,7 @@
       @Nullable String submissionId,
       @Nullable Change.Status status,
       Set<String> hashtags,
+      ImmutableSortedMap<String, String> customKeyedValues,
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
@@ -163,6 +165,7 @@
                 .cherryPickOf(cherryPickOf)
                 .build())
         .hashtags(hashtags)
+        .customKeyedValues(customKeyedValues.entrySet())
         .serverId(serverId)
         .patchSets(patchSets.entrySet())
         .approvals(approvals.entries())
@@ -290,6 +293,16 @@
   // Other related to this Change.
   abstract ImmutableSet<String> hashtags();
 
+  /*
+    Custom values are small key value pairs. They can be used to associate the
+    change with external, potentially proprietary systems (e.g. Bug trackers)
+    without requiring dedicated fields in Gerrit-core.
+
+    This data is visible to everyone who can see the change. It must not contain
+    personally identify-able information.
+  */
+  abstract ImmutableList<Map.Entry<String, String>> customKeyedValues();
+
   @Nullable
   abstract String serverId();
 
@@ -384,6 +397,7 @@
       return new AutoValue_ChangeNotesState.Builder()
           .changeId(changeId)
           .hashtags(ImmutableSet.of())
+          .customKeyedValues(ImmutableList.of())
           .patchSets(ImmutableList.of())
           .approvals(ImmutableList.of())
           .reviewers(ReviewerSet.empty())
@@ -411,6 +425,8 @@
 
     abstract Builder hashtags(Iterable<String> hashtags);
 
+    abstract Builder customKeyedValues(Iterable<Map.Entry<String, String>> customKeyedValues);
+
     abstract Builder patchSets(Iterable<Map.Entry<PatchSet.Id, PatchSet>> patchSets);
 
     abstract Builder approvals(Iterable<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals);
@@ -475,6 +491,14 @@
         b.setHasServerId(true);
       }
       object.hashtags().forEach(b::addHashtag);
+
+      object
+          .customKeyedValues()
+          .forEach(
+              entry -> {
+                b.putCustomKeyedValues(entry.getKey(), entry.getValue());
+              });
+
       object
           .patchSets()
           .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
@@ -614,6 +638,7 @@
               .columns(toChangeColumns(changeId, proto.getColumns()))
               .serverId(proto.getHasServerId() ? proto.getServerId() : null)
               .hashtags(proto.getHashtagList())
+              .customKeyedValues(proto.getCustomKeyedValuesMap().entrySet())
               .patchSets(
                   proto.getPatchSetList().stream()
                       .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 0a895fb..be755ea 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
@@ -100,6 +101,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -163,6 +165,7 @@
   private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
   private boolean ignoreFurtherAttentionSetUpdates;
   private Set<String> hashtags;
+  private TreeMap<String, String> customKeyedValues = new TreeMap<>();
   private String changeMessage;
   private String tag;
   private PatchSetState psState;
@@ -462,6 +465,14 @@
     this.hashtags = hashtags;
   }
 
+  public void addCustomKeyedValue(String key, String value) {
+    this.customKeyedValues.put(key, value);
+  }
+
+  public void deleteCustomKeyedValue(String key) {
+    this.customKeyedValues.put(key, "");
+  }
+
   /**
    * Adds attention set updates that should be stored in NoteDb.
    *
@@ -764,6 +775,10 @@
       addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
     }
 
+    for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+      addFooter(msg, FOOTER_CUSTOM_KEYED_VALUE, entry.getKey() + "=" + entry.getValue());
+    }
+
     if (tag != null) {
       addFooter(msg, FOOTER_TAG, tag);
     }
@@ -1159,6 +1174,7 @@
         && submissionId == null
         && submitRecords == null
         && hashtags == null
+        && customKeyedValues.isEmpty()
         && topic == null
         && commit == null
         && psState == null
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 9893d1a..eef913e 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.account.AccountQueryBuilder.FIELD_LIMIT;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.AndSource;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -78,7 +79,9 @@
   @Override
   protected Predicate<AccountState> enforceVisibility(Predicate<AccountState> pred) {
     return new AndSource<>(
-        pred, new AccountIsVisibleToPredicate(accountControlFactory.get()), start, indexConfig);
+        ImmutableList.of(pred, new AccountIsVisibleToPredicate(accountControlFactory.get())),
+        start,
+        indexConfig);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/AndChangeSource.java b/java/com/google/gerrit/server/query/change/AndChangeSource.java
index 98cada3..e4f768e 100644
--- a/java/com/google/gerrit/server/query/change/AndChangeSource.java
+++ b/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.AndSource;
-import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.index.query.Predicate;
 import java.util.Collection;
 import java.util.List;
@@ -28,11 +27,8 @@
   }
 
   public AndChangeSource(
-      Predicate<ChangeData> that,
-      IsVisibleToPredicate<ChangeData> isVisibleToPredicate,
-      int start,
-      IndexConfig indexConfig) {
-    super(that, isVisibleToPredicate, start, indexConfig);
+      Collection<Predicate<ChangeData>> that, int start, IndexConfig indexConfig) {
+    super(that, start, indexConfig);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 929aa94..46b85b7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -331,6 +331,7 @@
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
   private Set<String> hashtags;
+  private Map<String, String> customKeyedValues;
   /**
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
    * change and a given user.
@@ -1183,6 +1184,16 @@
     this.hashtags = hashtags;
   }
 
+  public Map<String, String> customKeyedValues() {
+    if (customKeyedValues == null) {
+      if (!lazyload()) {
+        return Collections.emptyMap();
+      }
+      customKeyedValues = notes().getCustomKeyedValues();
+    }
+    return customKeyedValues;
+  }
+
   public ImmutableListMultimap<Account.Id, String> stars() {
     if (stars == null) {
       if (!lazyload()) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 0f0535a..b7dc127 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
@@ -143,7 +144,9 @@
   @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
-        pred, changeIsVisibleToPredicateFactory.forUser(userProvider.get()), start, indexConfig);
+        ImmutableList.of(pred, changeIsVisibleToPredicateFactory.forUser(userProvider.get())),
+        start,
+        indexConfig);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index c6683fa..344a978 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.group.GroupQueryBuilder.FIELD_LIMIT;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.AndSource;
@@ -80,8 +81,8 @@
   @Override
   protected Predicate<InternalGroup> enforceVisibility(Predicate<InternalGroup> pred) {
     return new AndSource<>(
-        pred,
-        new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get()),
+        ImmutableList.of(
+            pred, new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get())),
         start,
         indexConfig);
   }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 6dafa92..3877c25 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.project.ProjectQueryBuilder.FIELD_LIMIT;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndexCollection;
@@ -82,8 +83,8 @@
   @Override
   protected Predicate<ProjectData> enforceVisibility(Predicate<ProjectData> pred) {
     return new AndSource<>(
-        pred,
-        new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get()),
+        ImmutableList.of(
+            pred, new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get())),
         start,
         indexConfig);
   }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 4ce8c42..dd0ec78d 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -21,7 +21,6 @@
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server:project_util",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index f18cc67..2544d3b 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.EmailModule.AddKeyEmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -58,7 +58,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final AddKeySender.Factory addKeyFactory;
+  private final AddKeyEmailFactories addKeyEmailFactories;
 
   @Inject
   AddSshKey(
@@ -66,12 +66,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      AddKeySender.Factory addKeyFactory) {
+      AddKeyEmailFactories addKeyEmailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.addKeyFactory = addKeyFactory;
+    this.addKeyEmailFactories = addKeyEmailFactories;
   }
 
   @Override
@@ -106,7 +106,7 @@
       AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
 
       try {
-        addKeyFactory.create(user, sshKey).send();
+        addKeyEmailFactories.createEmail(user, sshKey).send();
       } catch (EmailException e) {
         logger.atSevere().withCause(e).log(
             "Cannot send SSH key added message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index 70fbb26..92a7722 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,9 +37,11 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.EmailModule.RegisterNewEmailFactories;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +80,7 @@
   private final Realm realm;
   private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
-  private final RegisterNewEmailSender.Factory registerNewEmailFactory;
+  private final RegisterNewEmailFactories registerNewEmailFactories;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
   private final MessageIdGenerator messageIdGenerator;
@@ -92,7 +94,7 @@
       PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
-      RegisterNewEmailSender.Factory registerNewEmailFactory,
+      RegisterNewEmailFactories registerNewEmailFactories,
       PutPreferred putPreferred,
       OutgoingEmailValidator validator,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +103,7 @@
     this.realm = realm;
     this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
-    this.registerNewEmailFactory = registerNewEmailFactory;
+    this.registerNewEmailFactories = registerNewEmailFactories;
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
@@ -164,12 +166,14 @@
       }
     } else {
       try {
-        RegisterNewEmailSender emailSender = registerNewEmailFactory.create(email);
-        if (!emailSender.isAllowed()) {
+        RegisterNewEmailDecorator emailDecorator =
+            registerNewEmailFactories.createRegisterNewEmail(email);
+        if (!emailDecorator.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
-        emailSender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-        emailSender.send();
+        OutgoingEmail outgoingEmail = registerNewEmailFactories.createEmail(emailDecorator);
+        outgoingEmail.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+        outgoingEmail.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
         logger.atSevere().withCause(e).log("Cannot send email verification message to %s", email);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index e09e48f..61d43d2 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -51,7 +51,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
 
   @Inject
   DeleteSshKey(
@@ -59,12 +59,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      DeleteKeySender.Factory deleteKeySenderFactory) {
+      DeleteKeyEmailFactories deleteKeyEmailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
   }
 
   @Override
@@ -82,7 +82,7 @@
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, sshKey).send();
+      deleteKeyEmailFactories.createEmail(user, sshKey).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
           "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index 9361e27..edfc41c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -37,7 +37,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
+import com.google.gerrit.server.mail.EmailModule.HttpPasswordUpdateEmailFactory;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +78,7 @@
   private final PermissionBackend permissionBackend;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory;
+  private final HttpPasswordUpdateEmailFactory httpPasswordUpdateEmailFactory;
   private final ExternalIdFactory externalIdFactory;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
@@ -88,14 +88,14 @@
       PermissionBackend permissionBackend,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory,
+      HttpPasswordUpdateEmailFactory httpPasswordUpdateEmailFactory,
       ExternalIdFactory externalIdFactory,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.httpPasswordUpdateSenderFactory = httpPasswordUpdateSenderFactory;
+    this.httpPasswordUpdateEmailFactory = httpPasswordUpdateEmailFactory;
     this.externalIdFactory = externalIdFactory;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
@@ -146,8 +146,8 @@
                         extId.key(), extId.accountId(), extId.email(), newPassword)));
 
     try {
-      httpPasswordUpdateSenderFactory
-          .create(user, newPassword == null ? "deleted" : "added or updated")
+      httpPasswordUpdateEmailFactory
+          .createEmail(user, newPassword == null ? "deleted" : "added or updated")
           .send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 3ac4d22..2ff3ab0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -35,9 +35,10 @@
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.EmailModule.DeleteVoteChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.permissions.LabelRemovalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DeleteVoteControl;
@@ -76,7 +77,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+  private final DeleteVoteChangeEmailFactories deleteVoteChangeEmailFactories;
 
   private final DeleteVoteControl deleteVoteControl;
   private final RemoveReviewerControl removeReviewerControl;
@@ -99,7 +100,7 @@
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      DeleteVoteChangeEmailFactories deleteVoteChangeEmailFactories,
       DeleteVoteControl deleteVoteControl,
       MessageIdGenerator messageIdGenerator,
       RemoveReviewerControl removeReviewerControl,
@@ -113,7 +114,7 @@
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
     this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.deleteVoteChangeEmailFactories = deleteVoteChangeEmailFactories;
     this.deleteVoteControl = deleteVoteControl;
     this.removeReviewerControl = removeReviewerControl;
     this.messageIdGenerator = messageIdGenerator;
@@ -187,17 +188,18 @@
 
     CurrentUser user = ctx.getUser();
     try {
+      ChangeEmail changeEmail =
+          deleteVoteChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
+      changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+      OutgoingEmail outgoingEmail = deleteVoteChangeEmailFactories.createEmail(changeEmail);
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      ReplyToChangeSender emailSender =
-          deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
       if (user.isIdentifiedUser()) {
-        emailSender.setFrom(user.getAccountId());
+        outgoingEmail.setFrom(user.getAccountId());
       }
-      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
+      outgoingEmail.setNotify(notify);
+      outgoingEmail.setMessageId(
           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
+      outgoingEmail.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index a8f8adf..db6fa77 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -649,8 +649,8 @@
           del.add(c);
           update.putApproval(normName, (short) 0);
         }
-        // Only allow voting again the values are different, if the real account differs or if the
-        // vote is copied over from a past patch-set.
+        // Only allow voting again if the values are different, if the real account differs or if
+        // the vote is copied over from a past patch-set.
       } else if (c != null
           && (c.value() != ent.getValue()
               || !c.realAccountId().equals(reviewerId)
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
index b8afcb7..343fb72 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChain.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -317,10 +317,18 @@
 
   private List<PatchSetData> getChainForCurrentPatchSet(ChangeResource rsrc)
       throws PermissionBackendException, IOException {
-    return Lists.reverse(
-        getRelatedChangesUtil.getAncestors(
-            changeDataFactory.create(rsrc.getNotes()),
-            patchSetUtil.current(rsrc.getNotes()),
-            true));
+    List<PatchSetData> ancestors =
+        Lists.reverse(
+            getRelatedChangesUtil.getAncestors(
+                changeDataFactory.create(rsrc.getNotes()),
+                patchSetUtil.current(rsrc.getNotes()),
+                true));
+    int eldestOpenAncestor = 0;
+    for (PatchSetData ps : ancestors) {
+      if (ps.data().change().isMerged()) {
+        eldestOpenAncestor++;
+      }
+    }
+    return ancestors.subList(eldestOpenAncestor, ancestors.size());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 6ac9c21..35ea183 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -35,9 +35,10 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.EmailModule.RestoredChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -60,7 +61,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final BatchUpdate.Factory updateFactory;
-  private final RestoredSender.Factory restoredSenderFactory;
+  private final RestoredChangeEmailFactories restoredChangeEmailFactories;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
@@ -71,7 +72,7 @@
   @Inject
   Restore(
       BatchUpdate.Factory updateFactory,
-      RestoredSender.Factory restoredSenderFactory,
+      RestoredChangeEmailFactories restoredChangeEmailFactories,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
@@ -79,7 +80,7 @@
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
-    this.restoredSenderFactory = restoredSenderFactory;
+    this.restoredChangeEmailFactories = restoredChangeEmailFactories;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
@@ -151,13 +152,14 @@
     @Override
     public void postUpdate(PostUpdateContext ctx) {
       try {
-        ReplyToChangeSender emailSender =
-            restoredSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setMessageId(
+        ChangeEmail changeEmail =
+            restoredChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
+        changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+        OutgoingEmail outgoingEmail = restoredChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index e586216..0116804 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -223,6 +223,8 @@
     info.mergeabilityComputationBehavior =
         MergeabilityComputationBehavior.fromConfig(config).name();
     info.enableRobotComments = toBoolean(config.getBoolean("change", "enableRobotComments", true));
+    info.conflictsPredicateEnabled =
+        toBoolean(config.getBoolean("change", "conflictsPredicateEnabled", true));
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index 2f1153e..e30759d 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectResource;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 8203346..cfdadd9 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -35,7 +36,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 6174798..f9602bc 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -18,6 +18,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectUtil;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -32,7 +33,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 7aa3716..a823013 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -23,8 +23,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.EmailModule.MergedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -49,7 +51,7 @@
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final MergedSender.Factory mergedSenderFactory;
+  private final MergedChangeEmailFactories mergedChangeEmailFactories;
   private final ThreadLocalRequestContext requestContext;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -63,7 +65,7 @@
   @Inject
   EmailMerge(
       @SendEmailExecutor ExecutorService executor,
-      MergedSender.Factory mergedSenderFactory,
+      MergedChangeEmailFactories mergedChangeEmailFactories,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
@@ -73,7 +75,7 @@
       @Assisted RepoView repoView,
       @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.mergedChangeEmailFactories = mergedChangeEmailFactories;
     this.requestContext = requestContext;
     this.messageIdGenerator = messageIdGenerator;
     this.project = project;
@@ -93,18 +95,19 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender emailSender =
-          mergedSenderFactory.create(
+      ChangeEmail changeEmail =
+          mergedChangeEmailFactories.createChangeEmail(
               project,
               change.getId(),
               Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
+      OutgoingEmail outgoingEmail = mergedChangeEmailFactories.createEmail(changeEmail);
       if (submitter != null) {
-        emailSender.setFrom(submitter.getAccountId());
+        outgoingEmail.setFrom(submitter.getAccountId());
       }
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
+      outgoingEmail.setNotify(notify);
+      outgoingEmail.setMessageId(
           messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
-      emailSender.send();
+      outgoingEmail.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index 4775768..e960284 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -40,7 +40,7 @@
   private final CurrentUser caller;
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
-  private final RevCommit initialTip;
+  private final Set<RevCommit> uninterestingBranchTips;
   private final Set<RevCommit> alreadyAccepted;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Set<CodeReviewCommit> incoming;
@@ -48,7 +48,7 @@
   public RebaseSorter(
       CurrentUser caller,
       CodeReviewRevWalk rw,
-      RevCommit initialTip,
+      Set<RevCommit> uninterestingBranchTips,
       Set<RevCommit> alreadyAccepted,
       RevFlag canMergeFlag,
       Provider<InternalChangeQuery> queryProvider,
@@ -56,7 +56,7 @@
     this.caller = caller;
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
-    this.initialTip = initialTip;
+    this.uninterestingBranchTips = uninterestingBranchTips;
     this.alreadyAccepted = alreadyAccepted;
     this.queryProvider = queryProvider;
     this.incoming = incoming;
@@ -70,15 +70,16 @@
 
       rw.resetRetain(canMergeFlag);
       rw.markStart(n);
-      if (initialTip != null) {
-        rw.markUninteresting(initialTip);
+      for (RevCommit uninterestingBranchTip : uninterestingBranchTips) {
+        rw.markUninteresting(uninterestingBranchTip);
       }
 
       CodeReviewCommit c;
       final List<CodeReviewCommit> contents = new ArrayList<>();
       while ((c = rw.next()) != null) {
         if (!c.has(canMergeFlag) || !incoming.contains(c)) {
-          if (isAlreadyMerged(c, n.change().getDest())) {
+          if (isMergedInBranchAsSubmittedChange(c, n.change().getDest())
+              || isAlreadyMergedInAnyBranch(c)) {
             rw.markUninteresting(c);
           } else {
             // We cannot merge n as it would bring something we
@@ -108,7 +109,7 @@
     return sorted;
   }
 
-  private boolean isAlreadyMerged(CodeReviewCommit commit, BranchNameKey dest) throws IOException {
+  private boolean isAlreadyMergedInAnyBranch(CodeReviewCommit commit) throws IOException {
     try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
       mirw.markStart(commit);
@@ -120,22 +121,24 @@
           return true;
         }
       }
-
-      // check if the commit associated change is merged in the same branch
-      List<ChangeData> changes = queryProvider.get().byCommit(commit);
-      for (ChangeData change : changes) {
-        if (change.change().isMerged() && change.change().getDest().equals(dest)) {
-          logger.atFine().log(
-              "Dependency %s associated with merged change %s.", commit.getName(), change.getId());
-          return true;
-        }
-      }
       return false;
     } catch (StorageException e) {
       throw new IOException(e);
     }
   }
 
+  private boolean isMergedInBranchAsSubmittedChange(CodeReviewCommit commit, BranchNameKey dest) {
+    List<ChangeData> changes = queryProvider.get().byBranchCommit(dest, commit.getId().getName());
+    for (ChangeData change : changes) {
+      if (change.change().isMerged()) {
+        logger.atFine().log(
+            "Dependency %s associated with merged change %s.", commit.getName(), change.getId());
+        return true;
+      }
+    }
+    return false;
+  }
+
   private static <T> T removeOne(Collection<T> c) {
     final Iterator<T> i = c.iterator();
     final T r = i.next();
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index bdda3fc5..c7b322e 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmissionId;
@@ -217,11 +218,18 @@
           projectCache.get(destBranch.project()).orElseThrow(illegalState(destBranch.project()));
       this.mergeSorter =
           new MergeSorter(caller, rw, alreadyAccepted, canMergeFlag, queryProvider, incoming);
+      Set<RevCommit> uninterestingBranchTips;
+      if (project.is(BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET)) {
+        RevCommit initialTip = mergeTip.getInitialTip();
+        uninterestingBranchTips = initialTip == null ? Set.of() : Set.of(initialTip);
+      } else {
+        uninterestingBranchTips = alreadyAccepted;
+      }
       this.rebaseSorter =
           new RebaseSorter(
               caller,
               rw,
-              mergeTip.getInitialTip(),
+              uninterestingBranchTips,
               alreadyAccepted,
               canMergeFlag,
               queryProvider,
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 9250513..14d781d 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
+import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
@@ -39,6 +40,7 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -52,6 +54,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.RefLogIdentityProvider;
@@ -666,9 +669,14 @@
     }
   }
 
+  // For upstream implementation, AccessPath.WEB_BROWSER is never set, so the method will always
+  // return false.
+  @UsedAt(GOOGLE)
   private boolean indexAsync() {
-    return experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING);
+    return user.getAccessPath().equals(AccessPath.WEB_BROWSER)
+        && experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING,
+            project);
   }
 
   private void fireRefChangeEvent() {
@@ -751,6 +759,8 @@
         }
       }
       if (indexAsync) {
+        logger.atFine().log(
+            "Asynchronously reindexing changes, %s in project %s", results.keySet(), project.get());
         // We want to index asynchronously. However, the callers will await all
         // index futures. This allows us to - even in synchronous case -
         // parallelize indexing changes.
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 948b6e3..102b052 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -17,14 +17,17 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.AttentionSetSender;
+import com.google.gerrit.server.mail.EmailModule.AttentionSetChangeEmailFactories;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -42,15 +45,14 @@
     /**
      * factory for sending an email when adding users to the attention set or removing them from it.
      *
-     * @param sender sender in charge of sending the email, can be {@link AddToAttentionSetSender}
-     *     or {@link RemoveFromAttentionSetSender}.
+     * @param attentionSetChange whether the user is added or removed.
      * @param ctx context for sending the email.
      * @param change the change that the user was added/removed in.
      * @param reason reason for adding/removing the user.
      * @param attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
-        AttentionSetSender sender,
+        AttentionSetChange attentionSetChange,
         Context ctx,
         Change change,
         String reason,
@@ -66,7 +68,8 @@
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
-      @Assisted AttentionSetSender sender,
+      AttentionSetChangeEmailFactories attentionSetChangeEmailFactories,
+      @Assisted AttentionSetChange attentionSetChange,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
@@ -85,8 +88,10 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
+            attentionSetChangeEmailFactories,
             ctx.getUser(),
-            sender,
+            ctx.getProject(),
+            attentionSetChange,
             messageId,
             ctx.getNotify(change.getId()),
             attentionUserId,
@@ -107,8 +112,10 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final ThreadLocalRequestContext requestContext;
+    private final AttentionSetChangeEmailFactories attentionSetChangeEmailFactories;
     private final CurrentUser user;
-    private final AttentionSetSender sender;
+    private final AttentionSetChange attentionSetChange;
+    private final Project.NameKey projectId;
     private final MessageIdGenerator.MessageId messageId;
     private final NotifyResolver.Result notify;
     private final Account.Id attentionUserId;
@@ -117,16 +124,20 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
+        AttentionSetChangeEmailFactories attentionSetChangeEmailFactories,
         CurrentUser user,
-        AttentionSetSender sender,
+        Project.NameKey projectId,
+        AttentionSetChange attentionSetChange,
         MessageIdGenerator.MessageId messageId,
         NotifyResolver.Result notify,
         Account.Id attentionUserId,
         String reason,
         Change.Id changeId) {
       this.requestContext = requestContext;
+      this.attentionSetChangeEmailFactories = attentionSetChangeEmailFactories;
       this.user = user;
-      this.sender = sender;
+      this.projectId = projectId;
+      this.attentionSetChange = attentionSetChange;
       this.messageId = messageId;
       this.notify = notify;
       this.attentionUserId = attentionUserId;
@@ -138,18 +149,27 @@
     public void run() {
       RequestContext old = requestContext.setContext(this);
       try {
+        AttentionSetChangeEmailDecorator changeEmailParams =
+            attentionSetChangeEmailFactories.createAttentionSetChangeEmail();
+        changeEmailParams.setAttentionSetChange(attentionSetChange);
+        changeEmailParams.setAttentionSetUser(attentionUserId);
+        changeEmailParams.setReason(reason);
+        ChangeEmail changeEmail =
+            attentionSetChangeEmailFactories.createChangeEmail(
+                projectId, changeId, changeEmailParams);
+        OutgoingEmail outgoingEmail =
+            attentionSetChangeEmailFactories.createEmail(attentionSetChange, changeEmail);
+
         Optional<Account.Id> accountId =
             user.isIdentifiedUser()
                 ? Optional.of(user.asIdentifiedUser().getAccountId())
                 : Optional.empty();
         if (accountId.isPresent()) {
-          sender.setFrom(accountId.get());
+          outgoingEmail.setFrom(accountId.get());
         }
-        sender.setNotify(notify);
-        sender.setAttentionSetUser(attentionUserId);
-        sender.setReason(reason);
-        sender.setMessageId(messageId);
-        sender.send();
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
       } finally {
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 547aff3..a77ada4 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -544,6 +544,7 @@
     }
 
     @Override
+    @Nullable
     public String getRemoteName() {
       return null;
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 29ea7f4..ec474f1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -247,11 +247,14 @@
   private Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = "GerritBackendFeature__return_new_change_info_id")
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    ChangeInfo c = info(triplet);
-    assertThat(c.id).isEqualTo(triplet);
+    String id = project.get() + "~" + r.getChange().getId().get();
+    ChangeInfo c = info(id);
+    assertThat(c.id).isEqualTo(id);
     assertThat(c.project).isEqualTo(project.get());
     assertThat(c.branch).isEqualTo("master");
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index fd37fb0..5ecb5a7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -986,7 +986,7 @@
     public void rebaseChain() throws Exception {
       // Create changes with the following hierarchy:
       // * HEAD
-      //   * r1
+      //   * r (merged)
       //   * r2
       //     * r3
       //       * r4
@@ -1043,7 +1043,7 @@
       final String newContent = "new content";
       // Create changes with the following revision hierarchy:
       // * HEAD
-      //   * r1
+      //   * r (merged)
       //   * r2
       //     * r3/1    r3/2
       //       * r4
@@ -1079,6 +1079,71 @@
     }
 
     @Test
+    public void rebaseChainWithMergedAncestor() throws Exception {
+      final String file = "modified_file.txt";
+      final String newContent = "new content";
+
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r (merged)
+      //   * r2.1         r2.2 (merged)
+      //     * r3
+      //       * r4
+      //         *r5
+      PushOneCommit.Result r = createChange();
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+      PushOneCommit.Result r5 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+      testRepo.reset("HEAD~1");
+
+      // Create r2.2
+      gApi.changes()
+          .id(r2.getChangeId())
+          .edit()
+          .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+      gApi.changes().id(r2.getChangeId()).edit().publish();
+      // Approve and submit r2.2
+      revision = gApi.changes().id(r2.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r4.
+      verifyRebaseChainResponse(gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r3, r4);
+
+      // Only r3 and r4 are rebased.
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+
+      // r5 wasn't rebased.
+      assertThat(
+              gApi.changes()
+                  .id(r5.getChangeId())
+                  .get(CURRENT_REVISION)
+                  .getCurrentRevision()
+                  ._number)
+          .isEqualTo(1);
+
+      // Rebasing r5
+      verifyRebaseChainResponse(
+          gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r3, r4, r5);
+
+      verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+    }
+
+    @Test
     public void rebaseChainWithConflicts_conflictsForbidden() throws Exception {
       PushOneCommit.Result r1 = createChange();
       gApi.changes()
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index e57c82e..2796488 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -102,6 +102,7 @@
     return groups.getAllGroupReferences().map(GroupReference::getName);
   }
 
+  @SuppressWarnings("MathAbsoluteNegative")
   private static InternalGroupCreation getGroupCreation(String groupName, String groupUuid) {
     return InternalGroupCreation.builder()
         .setGroupUUID(AccountGroup.uuid(groupUuid))
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index ef2ca95..0d751f1 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -461,17 +461,19 @@
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/dev", superKey, "refs/heads/master");
 
-    ObjectId devHead = pushChangeTo(subRepo, "dev");
+    ObjectId revMasterBranch = pushChangeTo(subRepo, "master");
+    ObjectId revDevBranch = pushChangeTo(subRepo, "dev");
     Config config = new Config();
     prepareSubmoduleConfigEntry(config, subKey, nameKey("sub-master"), "master");
     prepareSubmoduleConfigEntry(config, subKey, nameKey("sub-dev"), "dev");
     pushSubmoduleConfig(superRepo, "master", config);
 
+    subRepo.reset(revMasterBranch);
     ObjectId subMasterId =
         pushChangeTo(
             subRepo, "refs/for/master", "some message", "b.txt", "content b", "same-topic");
 
-    subRepo.reset(devHead);
+    subRepo.reset(revDevBranch);
     ObjectId subDevId =
         pushChangeTo(
             subRepo, "refs/for/dev", "some message in dev", "b.txt", "content b", "same-topic");
@@ -673,14 +675,16 @@
 
     TestRepository<?> repoA = cloneProject(keyA);
     TestRepository<?> repoB = cloneProject(keyB);
-    // bootstrap the dev branch
-    ObjectId a0 = pushChangeTo(repoA, "dev");
 
-    // bootstrap the dev branch
-    ObjectId b0 = pushChangeTo(repoB, "dev");
+    // Create master- and dev branches in both repositories
+    ObjectId revMasterBranchA = pushChangeTo(repoA, "master");
+    ObjectId revMasterBranchB = pushChangeTo(repoB, "master");
+    ObjectId revDevBranchA = pushChangeTo(repoA, "dev");
+    ObjectId revDevBranchB = pushChangeTo(repoB, "dev");
 
     // create a change for master branch in repo a
-    ObjectId aHead =
+    repoA.reset(revMasterBranchA);
+    ObjectId revMasterChangeA =
         pushChangeTo(
             repoA,
             "refs/for/master",
@@ -690,7 +694,8 @@
             "same-topic");
 
     // create a change for master branch in repo b
-    ObjectId bHead =
+    repoB.reset(revMasterBranchB);
+    ObjectId revMasterChangeB =
         pushChangeTo(
             repoB,
             "refs/for/master",
@@ -700,8 +705,8 @@
             "same-topic");
 
     // create a change for dev branch in repo a
-    repoA.reset(a0);
-    ObjectId aDevHead =
+    repoA.reset(revDevBranchA);
+    ObjectId revDevChangeA =
         pushChangeTo(
             repoA,
             "refs/for/dev",
@@ -711,8 +716,8 @@
             "same-topic");
 
     // create a change for dev branch in repo b
-    repoB.reset(b0);
-    ObjectId bDevHead =
+    repoB.reset(revDevBranchB);
+    ObjectId revDevChangeB =
         pushChangeTo(
             repoB,
             "refs/for/dev",
@@ -721,12 +726,12 @@
             "some message in b dev.txt",
             "same-topic");
 
-    approve(getChangeId(repoA, aHead).get());
-    approve(getChangeId(repoB, bHead).get());
-    approve(getChangeId(repoA, aDevHead).get());
-    approve(getChangeId(repoB, bDevHead).get());
+    approve(getChangeId(repoA, revMasterChangeA).get());
+    approve(getChangeId(repoB, revMasterChangeB).get());
+    approve(getChangeId(repoA, revDevChangeA).get());
+    approve(getChangeId(repoB, revDevChangeB).get());
 
-    gApi.changes().id(getChangeId(repoA, aDevHead).get()).current().submit();
+    gApi.changes().id(getChangeId(repoA, revDevChangeA).get()).current().submit();
     assertThat(projectOperations.project(keyA).getHead("refs/heads/master").getShortMessage())
         .contains("some message in a master.txt");
     assertThat(projectOperations.project(keyA).getHead("refs/heads/dev").getShortMessage())
@@ -746,8 +751,9 @@
     // bootstrap the dev branch
     pushChangeTo(repoA, "dev");
 
-    // bootstrap the dev branch
-    ObjectId b0 = pushChangeTo(repoB, "dev");
+    // Create master- and dev branches in repo b
+    ObjectId revMasterBranch = pushChangeTo(repoB, "master");
+    ObjectId revDevBranch = pushChangeTo(repoB, "dev");
 
     allowMatchingSubmoduleSubscription(keyB, "refs/heads/master", keyA, "refs/heads/master");
     allowMatchingSubmoduleSubscription(keyB, "refs/heads/dev", keyA, "refs/heads/dev");
@@ -756,7 +762,8 @@
     createSubmoduleSubscription(repoA, "dev", keyB, "dev");
 
     // create a change for master branch in repo b
-    ObjectId bHead =
+    repoB.reset(revMasterBranch);
+    ObjectId revMasterChange =
         pushChangeTo(
             repoB,
             "refs/for/master",
@@ -766,8 +773,8 @@
             "same-topic");
 
     // create a change for dev branch in repo b
-    repoB.reset(b0);
-    ObjectId bDevHead =
+    repoB.reset(revDevBranch);
+    ObjectId revDevChange =
         pushChangeTo(
             repoB,
             "refs/for/dev",
@@ -776,9 +783,9 @@
             "some message in b dev.txt",
             "same-topic");
 
-    approve(getChangeId(repoB, bHead).get());
-    approve(getChangeId(repoB, bDevHead).get());
-    gApi.changes().id(getChangeId(repoB, bHead).get()).current().submit();
+    approve(getChangeId(repoB, revMasterChange).get());
+    approve(getChangeId(repoB, revDevChange).get());
+    gApi.changes().id(getChangeId(repoB, revMasterChange).get()).current().submit();
 
     expectToHaveSubmoduleState(repoA, "master", keyB, repoB, "master");
     expectToHaveSubmoduleState(repoA, "dev", keyB, repoB, "dev");
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 15baa78..abaf98f 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1262,6 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
@@ -1274,6 +1275,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
@@ -1288,6 +1290,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
@@ -1300,6 +1303,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
@@ -1312,6 +1316,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
@@ -1324,6 +1329,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index f728995..5e00230 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -62,7 +62,7 @@
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
     String hostname = URI.create(canonicalWebUrl.get()).getHost();
     String listId = String.format("<gerrit-%s.%s>", project.get(), hostname);
-    String unsubscribeLink = String.format("<%ssettings>", canonicalWebUrl.get());
+    String unsubscribeLink = String.format("<%ssettings?usp=email>", canonicalWebUrl.get());
     String threadId =
         String.format(
             "<gerrit.%s.%s@%s>",
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 3508112..7a55ecb 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupDescription;
@@ -123,6 +124,7 @@
                       }
 
                       @Override
+                      @Nullable
                       public String getUrl() {
                         return null;
                       }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index 6e67d5f..e13661e 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -87,6 +87,35 @@
   }
 
   @Test
+  public void pluginConfig_inheritanceCanOverrideValuesAndKeepsRest() throws Exception {
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin2",
+              cfg -> {
+                cfg.setString("key", "kept");
+                cfg.setString("key2", "my-plugin-value2");
+              });
+      u.save();
+    }
+
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin2",
+              cfg -> {
+                cfg.setString("key2", "overridden");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig =
+        pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin2");
+    assertThat(pluginConfig.getString("key")).isEqualTo("kept");
+    assertThat(pluginConfig.getString("key2")).isEqualTo("overridden");
+  }
+
+  @Test
   public void allProjectsProjectsConfig_ChangeInFileInvalidatesPersistedCache() throws Exception {
     assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isTrue();
     // Change etc/All-Projects-project.config
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
index 20f4ca6..911c85c 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
@@ -1,11 +1,7 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-    srcs = glob(
-        ["*IT.java"],
-        exclude = ["prolog/*.java"],
-    ),
+    srcs = glob(["*IT.java"]),
     group = "server_rules",
     labels = ["server"],
-    deps = [],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
index 3d28dea..03c24a2 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
@@ -1,10 +1,8 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-    srcs = glob(
-        ["*IT.java"],
-    ),
-    group = "server_rules",
+    srcs = glob(["*IT.java"]),
+    group = "prolog_rules",
     labels = ["server"],
     deps = [
         "//java/com/google/gerrit/server/rules/prolog",
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD
index 6a41d01..fa30f80a 100644
--- a/javatests/com/google/gerrit/auth/BUILD
+++ b/javatests/com/google/gerrit/auth/BUILD
@@ -7,8 +7,6 @@
     srcs = glob(
         ["**/*.java"],
     ),
-    resource_strip_prefix = "resources",
-    resources = ["//resources/com/google/gerrit/server"],
     tags = ["no_windows"],
     visibility = ["//visibility:public"],
     runtime_deps = [
diff --git a/javatests/com/google/gerrit/common/data/BUILD b/javatests/com/google/gerrit/common/data/BUILD
index f2b7d63..154fd89 100644
--- a/javatests/com/google/gerrit/common/data/BUILD
+++ b/javatests/com/google/gerrit/common/data/BUILD
@@ -4,6 +4,7 @@
     name = "data_tests",
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 477f9d2..ba4b586 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.GroupDescription;
@@ -33,6 +34,7 @@
             new GroupDescription.Basic() {
 
               @Override
+              @Nullable
               public String getUrl() {
                 return null;
               }
@@ -48,6 +50,7 @@
               }
 
               @Override
+              @Nullable
               public String getEmailAddress() {
                 return null;
               }
diff --git a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
index f653759..eb3358d 100644
--- a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
@@ -57,6 +57,7 @@
     }
 
     @Operator
+    @SuppressWarnings("unused")
     public Predicate<Object> a(String value) {
       return new TestPredicate("a", value);
     }
diff --git a/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java b/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
index b969d68..7cc4b2e 100644
--- a/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
+++ b/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
@@ -49,6 +49,7 @@
   @Mock ServletContext context;
 
   @Test
+  @SuppressWarnings("DoNotCall")
   public void shouldCallTaskEndOnListenerCompleteFromDifferentThread() {
     ProjectQoSFilter.TaskThunk taskThunk = getTaskThunk();
     ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
@@ -71,6 +72,7 @@
   }
 
   @Test
+  @SuppressWarnings("DoNotCall")
   public void shouldCallTaskEndOnListenerTimeoutFromDifferentThread() {
     ProjectQoSFilter.TaskThunk taskThunk = getTaskThunk();
     ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
@@ -93,6 +95,7 @@
   }
 
   @Test
+  @SuppressWarnings("DoNotCall")
   public void shouldCallTaskEndOnListenerErrorFromDifferentThread() {
     ProjectQoSFilter.TaskThunk taskThunk = getTaskThunk();
     ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index b5c6149..4821f20 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -25,8 +25,6 @@
         ["**/*.java"],
         exclude = CUSTOM_TRUTH_SUBJECTS,
     ),
-    resource_strip_prefix = "resources",
-    resources = ["//resources/com/google/gerrit/server"],
     tags = ["no_windows"],
     visibility = ["//visibility:public"],
     runtime_deps = [
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 8f2d613..0a874db 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -183,7 +183,7 @@
   public void handlesTreePrefixesInDifferentialReload() throws Exception {
     // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
     // created a situation where NoteNames are sharded.
-    ObjectId oldState = inserExternalIds(257);
+    ObjectId oldState = insertExternalIds(257);
     assertAllFilesHaveSlashesInPath();
     ObjectId head = insertExternalId(500, 500);
     externalIdCache.put(oldState, allFromGit(oldState));
@@ -196,7 +196,7 @@
   @Test
   public void handlesReshard() throws Exception {
     // Create 256 notes (NoteMap's current sharding limit) and check that we are not yet sharding
-    ObjectId oldState = inserExternalIds(256);
+    ObjectId oldState = insertExternalIds(256);
     assertNoFilesHaveSlashesInPath();
     // Create one more external ID and then have the Loader compute the new state
     ObjectId head = insertExternalId(500, 500);
@@ -223,7 +223,7 @@
     return AllExternalIds.create(externalIdReader.all(revision).stream());
   }
 
-  private ObjectId inserExternalIds(int numberOfIdsToInsert) throws Exception {
+  private ObjectId insertExternalIds(int numberOfIdsToInsert) throws Exception {
     ObjectId oldState = null;
     // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
     // created a situation where NoteNames are sharded.
diff --git a/javatests/com/google/gerrit/server/cache/mem/BUILD b/javatests/com/google/gerrit/server/cache/mem/BUILD
index e50d95f..baa6ff8 100644
--- a/javatests/com/google/gerrit/server/cache/mem/BUILD
+++ b/javatests/com/google/gerrit/server/cache/mem/BUILD
@@ -4,6 +4,7 @@
     name = "tests",
     srcs = glob(["*Test.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
index 42b74e3..e7dbbfe 100644
--- a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
@@ -22,6 +22,7 @@
 import com.google.common.cache.RemovalNotification;
 import com.google.common.cache.Weigher;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.cache.CacheDef;
@@ -282,11 +283,13 @@
       }
 
       @Override
+      @Nullable
       public TypeLiteral<Integer> keyType() {
         return null;
       }
 
       @Override
+      @Nullable
       public TypeLiteral<Integer> valueType() {
         return null;
       }
@@ -297,26 +300,31 @@
       }
 
       @Override
+      @Nullable
       public Duration expireAfterWrite() {
         return null;
       }
 
       @Override
+      @Nullable
       public Duration expireFromMemoryAfterAccess() {
         return null;
       }
 
       @Override
+      @Nullable
       public Duration refreshAfterWrite() {
         return null;
       }
 
       @Override
+      @Nullable
       public Weigher<Integer, Integer> weigher() {
         return null;
       }
 
       @Override
+      @Nullable
       public CacheLoader<Integer, Integer> loader() {
         return null;
       }
diff --git a/javatests/com/google/gerrit/server/ioutil/BUILD b/javatests/com/google/gerrit/server/ioutil/BUILD
index ac9530f..8654522 100644
--- a/javatests/com/google/gerrit/server/ioutil/BUILD
+++ b/javatests/com/google/gerrit/server/ioutil/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(
         ["**/*.java"],
     ),
-    resource_strip_prefix = "resources",
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/server/ioutil",
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
similarity index 68%
rename from javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
rename to javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
index d7a6282..ae45209 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
@@ -18,44 +18,41 @@
 
 import java.util.Collections;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
-public class CommentSenderTest {
-  private static class TestSender extends CommentSender {
-    TestSender() {
-      super(null, null, null, null, null, null, null);
-    }
-  }
-
+@RunWith(JUnit4.class)
+public class CommentChangeEmailDecoratorTest {
   // A 100-character long string.
   private static String chars100 = String.join("", Collections.nCopies(25, "abcd"));
 
   @Test
   public void shortMessageNotShortened() {
     String message = "foo bar baz";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(message);
 
     message = "foo bar baz.";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(message);
   }
 
   @Test
   public void longMessageIsShortened() {
     String message = chars100 + "x";
     String expected = chars100 + " […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(expected);
   }
 
   @Test
   public void shortenedToFirstLine() {
     String message = "abc\n" + chars100;
     String expected = "abc […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(expected);
   }
 
   @Test
   public void shortenedToFirstSentence() {
     String message = "foo bar baz. " + chars100;
     String expected = "foo bar baz. […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(expected);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 9a29230..d04964b 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -65,6 +65,7 @@
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.AbstractMap.SimpleEntry;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -334,6 +335,24 @@
   }
 
   @Test
+  public void serializeCustomKeyedValues() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .customKeyedValues(
+                ImmutableList.of(
+                    new SimpleEntry<String, String>("key1", "value1"),
+                    new SimpleEntry<String, String>("key2", "value2")))
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .putCustomKeyedValues("key1", "value1")
+            .putCustomKeyedValues("key2", "value2")
+            .build());
+  }
+
+  @Test
   public void serializePatchSets() throws Exception {
     PatchSet ps1 =
         PatchSet.builder()
@@ -918,6 +937,9 @@
                 .put("columns", ChangeColumns.class)
                 .put("hashtags", new TypeLiteral<ImmutableSet<String>>() {}.getType())
                 .put(
+                    "customKeyedValues",
+                    new TypeLiteral<ImmutableList<Map.Entry<String, String>>>() {}.getType())
+                .put(
                     "patchSets",
                     new TypeLiteral<ImmutableList<Map.Entry<PatchSet.Id, PatchSet>>>() {}.getType())
                 .put(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 15eefcd..69b1870 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -34,6 +34,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -67,6 +68,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -1504,6 +1506,78 @@
   }
 
   @Test
+  public void customKeyedValuesCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+    try (RevWalk walk = new RevWalk(repo)) {
+      RevCommit commit = walk.parseCommit(update.getResult());
+      walk.parseBody(commit);
+      assertThat(commit.getFullMessage()).contains("Custom-Keyed-Value: key1=value1\n");
+      assertThat(commit.getFullMessage()).contains("Custom-Keyed-Value: key2=value2\n");
+    }
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value\n1");
+    update.addCustomKeyedValue("key2", "value2=value3");
+    update.addCustomKeyedValue("key3", "value3: value4");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key1", "value 1");
+    customKeyedValues.put("key2", "value2=value3");
+    customKeyedValues.put("key3", "value3: value4");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes_Override() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value3");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key1", "value3");
+    customKeyedValues.put("key2", "value2");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes_Deletion() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.deleteCustomKeyedValue("key1");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key2", "value2");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
   public void topicChangeNotes() throws Exception {
     Change c = newChange();
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a188251..688e5e4 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -83,6 +83,7 @@
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -2886,7 +2887,8 @@
   }
 
   @Test
-  public void byStar() throws Exception {
+  public void byStar_withStarOptionSet() throws Exception {
+    // When star option is set, the 'starred' field is set in the change infos in response.
     repo = createAndOpenProject("repo");
     Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
@@ -2899,6 +2901,32 @@
     // check default star
     assertQuery("has:star", change1);
     assertQuery("is:starred", change1);
+
+    // The 'Star' bit in the change data is also set correctly
+    List<ChangeInfo> changeInfos =
+        gApi.changes().query("has:star").withOptions(ListChangesOption.STAR).get();
+    assertThat(changeInfos.get(0).starred).isTrue();
+  }
+
+  @Test
+  public void byStar_withStarOptionNotSet() throws Exception {
+    // When star option is not set, the 'starred' field is not set in the change infos in response.
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+
+    gApi.accounts().self().starChange(change1.getId().toString());
+
+    // check default star
+    assertQuery("has:star", change1);
+    assertQuery("is:starred", change1);
+
+    // The 'Star' bit in the change data is not set if the backfilling option is not set
+    List<ChangeInfo> changeInfos = gApi.changes().query("has:star").get();
+    assertThat(changeInfos.get(0).starred).isNull();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 5c57ede..3732bd4 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -1,22 +1,11 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "prolog_tests",
+    name = "rules_tests",
     srcs = glob(["*.java"]),
-    resource_strip_prefix = "prologtests",
-    resources = ["//prologtests:gerrit_common_test"],
-    runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib/guice",
-        "//lib/mockito",
-        "//lib/prolog:runtime",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/rules/prolog/BUILD b/javatests/com/google/gerrit/server/rules/prolog/BUILD
new file mode 100644
index 0000000..ce02a06
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/prolog/BUILD
@@ -0,0 +1,23 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "prolog_tests",
+    srcs = glob(["*.java"]),
+    resource_strip_prefix = "prologtests",
+    resources = ["//prologtests:gerrit_common_test"],
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/guice",
+        "//lib/mockito",
+        "//lib/prolog:runtime",
+        "//lib/truth",
+    ],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 1a7835b..be8e04b 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 1a7835bdfa55d60c717bad20b943e34499da0016
+Subproject commit be8e04b1a6de091a63c9bc79b56508f2ad56a830
diff --git a/plugins/delete-project b/plugins/delete-project
index b183ee5..79674d9 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit b183ee5230273670f3235cc5b3cf32562ccfb7ee
+Subproject commit 79674d9e00f8458a2f4f6d3a91bd032579c3f25c
diff --git a/plugins/package.json b/plugins/package.json
index 612062b..504fc17 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -9,28 +9,29 @@
     "@open-wc/testing": "^3.1.6",
     "@web/dev-server-esbuild": "^0.3.2",
     "@web/test-runner": "^0.14.0",
-    "@codemirror/autocomplete": "^6.4.2",
-    "@codemirror/commands": "^6.2.1",
-    "@codemirror/legacy-modes": "^6.3.1",
+    "@codemirror/autocomplete": "^6.5.1",
+    "@codemirror/commands": "^6.2.3",
+    "@codemirror/legacy-modes": "^6.3.2",
     "@codemirror/lang-cpp": "^6.0.2",
-    "@codemirror/lang-css": "^6.0.2",
-    "@codemirror/lang-html": "^6.4.2",
+    "@codemirror/lang-css": "^6.2.0",
+    "@codemirror/lang-html": "^6.4.3",
     "@codemirror/lang-java": "^6.0.1",
-    "@codemirror/lang-javascript": "^6.1.4",
+    "@codemirror/lang-javascript": "^6.1.7",
     "@codemirror/lang-json": "^6.0.1",
-    "@codemirror/lang-markdown": "^6.1.0",
+    "@codemirror/lang-less": "^6.0.0",
+    "@codemirror/lang-markdown": "^6.1.1",
     "@codemirror/lang-php": "^6.0.1",
-    "@codemirror/lang-python": "^6.1.1",
+    "@codemirror/lang-python": "^6.1.2",
     "@codemirror/lang-rust": "^6.0.1",
-    "@codemirror/lang-sql": "^6.4.0",
+    "@codemirror/lang-sass": "^6.0.1",
+    "@codemirror/lang-sql": "^6.4.1",
     "@codemirror/lang-xml": "^6.0.2",
-    "@codemirror/language": "^6.2.0",
-    "@codemirror/language-data": "^6.1.0",
-    "@codemirror/lint": "^6.1.1",
-    "@codemirror/search": "^6.2.3",
+    "@codemirror/language": "^6.6.0",
+    "@codemirror/language-data": "^6.3.0",
+    "@codemirror/lint": "^6.2.1",
+    "@codemirror/search": "^6.4.0",
     "@codemirror/state": "^6.2.0",
-    "@codemirror/theme-one-dark": "^6.1.1",
-    "@codemirror/view": "^6.9.1",
+    "@codemirror/view": "^6.10.0",
     "lit": "^2.2.3",
     "rxjs": "^6.6.7",
     "sinon": "^13.0.0"
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 158435d..9321303 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 158435d2aa9f8729e4e78835969e54701af9203b
+Subproject commit 9321303265fcab2ff7f764a444f8c23915747638
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 3156f4c..3f53453 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -23,26 +23,37 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.4.2":
-  version "6.4.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.4.2.tgz#938b25223bd21f97b2a6d85474643355f98b505b"
-  integrity sha512-8WE2xp+D0MpWEv5lZ6zPW1/tf4AGb358T5GWYiKEuCP8MvFfT3tH2mIF9Y2yr2e3KbHuSvsVhosiEyqCpiJhZQ==
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.5.1":
+  version "6.5.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.5.1.tgz#539cfff291dbffd3841cba078b222cea28ff7eda"
+  integrity sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.6.0"
     "@lezer/common" "^1.0.0"
 
-"@codemirror/commands@^6.2.1":
-  version "6.2.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.1.tgz#ab5e979ad1458bbe395bf69ac601f461ac73cf08"
-  integrity sha512-FFiNKGuHA5O8uC6IJE5apI5rT9gyjlw4whqy4vlcX0wE/myxL6P1s0upwDhY4HtMWLOwzwsp0ap3bjdQhvfDOA==
+"@codemirror/commands@^6.2.3":
+  version "6.2.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.3.tgz#ec476fd588f7a4333f54584d4783dd3862befe3b"
+  integrity sha512-9uf0g9m2wZyrIim1SavcxMdwsu8wc/y5uSw6JRUBYIGWrN+RY4vSru/BqB+MyNWqx4C2uRhQ/Kh7Pw8lAyT3qQ==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.2.0"
     "@codemirror/view" "^6.0.0"
     "@lezer/common" "^1.0.0"
 
+"@codemirror/lang-angular@^0.1.0":
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.0.tgz#1054747c8196357a2aee2b9c36f0f6de9a6ffef9"
+  integrity sha512-vTjoHjzJmLrrMFmf/tojwp+O0P+R9mgWtjjaKDNDoY58PzOPg7ldMEBqIzABBc+/2mYPD85SG7O5byfBxc83eA==
+  dependencies:
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/lang-javascript" "^6.1.2"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+
 "@codemirror/lang-cpp@^6.0.0", "@codemirror/lang-cpp@^6.0.2":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@codemirror/lang-cpp/-/lang-cpp-6.0.2.tgz#076c98340c3beabde016d7d83e08eebe17254ef9"
@@ -51,20 +62,21 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/cpp" "^1.0.0"
 
-"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.0.2":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.0.2.tgz#b286d0226755a751f60599e1e2969d351aebbd4c"
-  integrity sha512-4V4zmUOl2Glx0GWw0HiO1oGD4zvMlIQ3zx5hXOE6ipCjhohig2bhWRAasrZylH9pRNTcl1VMa59Lsl8lZWlTzw==
+"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.1.1", "@codemirror/lang-css@^6.2.0":
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.0.tgz#f84f9da392099432445c75e32fdac63ae572315f"
+  integrity sha512-oyIdJM29AyRPM3+PPq1I2oIk8NpUfEN3kAM05XWDDs6o3gSneIKaVJifT2P+fqONLou2uIgXynFyMUDQvo/szA==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.2"
     "@lezer/css" "^1.0.0"
 
-"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.2":
-  version "6.4.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.2.tgz#3c7117e45bae009bc7bc08eef8a79b5d05930d83"
-  integrity sha512-bqCBASkteKySwtIbiV/WCtGnn/khLRbbiV5TE+d9S9eQJD7BA4c5dTRm2b3bVmSpilff5EYxvB4PQaZzM/7cNw==
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.3":
+  version "6.4.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.3.tgz#dec78f76d9d0261cbe9f2a3a247a1b546327f700"
+  integrity sha512-VKzQXEC8nL69Jg2hvAFPBwOdZNvL8tMFOrdFwWpU+wc6a6KEkndJ/19R5xSaglNX6v2bttm8uIEFYxdQDcIZVQ==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
@@ -84,10 +96,10 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/java" "^1.0.0"
 
-"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.4":
-  version "6.1.4"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.1.4.tgz#8a41f4d213e1143b4eef6f65f8b77b349aaf894c"
-  integrity sha512-OxLf7OfOZBTMRMi6BO/F72MNGmgOd9B0vetOLvHsDACFXayBzW8fm8aWnDM0yuy68wTK03MBf4HbjSBNRG5q7A==
+"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.1.7":
+  version "6.1.7"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.1.7.tgz#e39fb9757b1cf47de432e4244d18ca5284a73a58"
+  integrity sha512-KXKqxlZ4W6t5I7i2ScmITUD3f/F5Cllk3kj0De9P9mFeYVfhOVOWuDLgYiLpk357u7Xh4dhqjJAnsNPPoTLghQ==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.6.0"
@@ -105,10 +117,20 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/json" "^1.0.0"
 
-"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.1.0":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.1.0.tgz#218a6ddcd6ea25039e143058fbc95cfd82f003b4"
-  integrity sha512-HQDJg1Js19fPKKsI3Rp1X0J6mxyrRy2NX6+Evh0+/jGm6IZHL5ygMGKBYNWKXodoDQFvgdofNRG33gWOwV59Ag==
+"@codemirror/lang-less@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.0.tgz#47ac36242f45bcc211dbcbce11e10f3b249519c9"
+  integrity sha512-hQVj+AxcUW/LybRkwaOope8K8+U6bjWH91t0tW8MMok33Y65xo+Wx0t1BaXi3Iuo6CgJ4tW7Rz09cfNwloIdNA==
+  dependencies:
+    "@codemirror/lang-css" "^6.2.0"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.1.1.tgz#ff3cdd339c277f6a02d08eb12f1090977873e771"
+  integrity sha512-n87Ms6Y5UYb1UkFu8sRzTLfq/yyF1y2AYiWvaVdbBQi5WDj1tFk5N+AKA+WC0Jcjc1VxvrCCM0iizjdYYi9sFQ==
   dependencies:
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/language" "^6.3.0"
@@ -128,7 +150,7 @@
     "@lezer/common" "^1.0.0"
     "@lezer/php" "^1.0.0"
 
-"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.1":
+"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.2":
   version "6.1.2"
   resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.2.tgz#cabb57529679981f170491833dbf798576e7ab18"
   integrity sha512-nbQfifLBZstpt6Oo4XxA2LOzlSp4b/7Bc5cmodG1R+Cs5PLLCTUvsMNWDnziiCfTOG/SW1rVzXq/GbIr6WXlcw==
@@ -145,10 +167,21 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/rust" "^1.0.0"
 
-"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.4.0":
-  version "6.4.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.4.0.tgz#f9303e511fb9511884f90043e354d5df3bd4b032"
-  integrity sha512-UWGK1+zc9+JtkiT+XxHByp4N6VLgLvC2x0tIudrJG26gyNtn0hWOVoB0A8kh/NABPWkKl3tLWDYf2qOBJS9Zdw==
+"@codemirror/lang-sass@^6.0.0", "@codemirror/lang-sass@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sass/-/lang-sass-6.0.1.tgz#e390f427c8601175f155046e142371c3c4fb718c"
+  integrity sha512-USy9zqtdLYxSuqq0s4peMoQi+BDzyOyO7chUzli+X2xVCjmBhc3CsWQ4kkDU0NYtCHHFQRkcFO8770eaOwZqfw==
+  dependencies:
+    "@codemirror/lang-css" "^6.1.1"
+    "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.0.2"
+    "@lezer/sass" "^1.0.0"
+
+"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.4.1":
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.4.1.tgz#e680fe8c12e5902a29fd952207bf454ae02b3bdc"
+  integrity sha512-PFB56L+A0WGY35uRya+Trt5g19V9k2V9X3c55xoFW4RgiATr/yLqWsbbnEsdxuMn5tLpuikp7Kmj9smRsqBXAg==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
@@ -156,6 +189,18 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
+"@codemirror/lang-vue@^0.1.1":
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.1.tgz#79567fb3be3f411354cd135af59d67f956cdb042"
+  integrity sha512-GIfc/MemCFKUdNSYGTFZDN8XsD2z0DUY7DgrK34on0dzdZ/CawZbi+SADYfVzWoPPdxngHzLhqlR5pSOqyPCvA==
+  dependencies:
+    "@codemirror/lang-html" "^6.0.0"
+    "@codemirror/lang-javascript" "^6.1.2"
+    "@codemirror/language" "^6.0.0"
+    "@lezer/common" "^1.0.0"
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.3.1"
+
 "@codemirror/lang-wast@^6.0.0":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/@codemirror/lang-wast/-/lang-wast-6.0.1.tgz#c15bec84548a5e9b0a43fa69fb63631d087d6047"
@@ -176,11 +221,12 @@
     "@lezer/common" "^1.0.0"
     "@lezer/xml" "^1.0.0"
 
-"@codemirror/language-data@^6.1.0":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.1.0.tgz#479eff66289a6453493f7c8213d7b2ceb95c89f6"
-  integrity sha512-g9V23fuLRI9AEbpM6bDy1oquqgpFlIDHTihUhL21NPmxp+x67ZJbsKk+V71W7/Bj8SCqEO1PtqQA/tDGgt1nfw==
+"@codemirror/language-data@^6.3.0":
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.3.0.tgz#058365fc2e857eb48810ed92134ee469d9a9bba6"
+  integrity sha512-D9tOZS38mK59jDs1Flqe8GgCdUAYI339SqBdwHJZwxgyXHsBc8RIhAlz2oXWGpvZeP/kVHy9LVfoBFgO02mx7w==
   dependencies:
+    "@codemirror/lang-angular" "^0.1.0"
     "@codemirror/lang-cpp" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
     "@codemirror/lang-html" "^6.0.0"
@@ -191,13 +237,15 @@
     "@codemirror/lang-php" "^6.0.0"
     "@codemirror/lang-python" "^6.0.0"
     "@codemirror/lang-rust" "^6.0.0"
+    "@codemirror/lang-sass" "^6.0.0"
     "@codemirror/lang-sql" "^6.0.0"
+    "@codemirror/lang-vue" "^0.1.1"
     "@codemirror/lang-wast" "^6.0.0"
     "@codemirror/lang-xml" "^6.0.0"
     "@codemirror/language" "^6.0.0"
     "@codemirror/legacy-modes" "^6.1.0"
 
-"@codemirror/language@^6.0.0", "@codemirror/language@^6.2.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
   version "6.6.0"
   resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.6.0.tgz#2204407174a38a68053715c19e28ad61f491779f"
   integrity sha512-cwUd6lzt3MfNYOobdjf14ZkLbJcnv4WtndYaoBkbor/vF+rCNguMPK0IRtvZJG4dsWiaWPcK8x1VijhvSxnstg==
@@ -209,26 +257,26 @@
     "@lezer/lr" "^1.0.0"
     style-mod "^4.0.0"
 
-"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.1":
-  version "6.3.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.1.tgz#77ab3f3db1ce3e47aad7a5baac3a4b12844734a5"
-  integrity sha512-icXmCs4Mhst2F8mE0TNpmG6l7YTj1uxam3AbZaFaabINH5oWAdg2CfR/PVi+d/rqxJ+TuTnvkKK5GILHrNThtw==
+"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.2":
+  version "6.3.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.2.tgz#d5616b453f38866717437b51c16bde1ae3f011ec"
+  integrity sha512-ki5sqNKWzKi5AKvpVE6Cna4Q+SgxYuYVLAZFSsMjGBWx5qSVa+D+xipix65GS3f2syTfAD9pXKMX4i4p49eneQ==
   dependencies:
     "@codemirror/language" "^6.0.0"
 
-"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.1.1":
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.2.0.tgz#25cdab7425fcda1b38a9d63f230f833c8b6b369f"
-  integrity sha512-KVCECmR2fFeYBr1ZXDVue7x3q5PMI0PzcIbA+zKufnkniMBo1325t0h1jM85AKp8l3tj67LRxVpZfgDxEXlQkg==
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.2.1":
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.2.1.tgz#654581d8cc293c315ecfa5c9d61d78c52bbd9ccd"
+  integrity sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
     crelt "^1.0.5"
 
-"@codemirror/search@^6.2.3":
-  version "6.2.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.2.3.tgz#fab933fef1b1de8ef40cda275c73d9ac7a1ff40f"
-  integrity sha512-V9n9233lopQhB1dyjsBK2Wc1i+8hcCqxl1wQ46c5HWWLePoe4FluV3TGHoZ04rBRlGjNyz9DTmpJErig8UE4jw==
+"@codemirror/search@^6.4.0":
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.4.0.tgz#2b256a9e0eaa9317fb48e3cc81eb2735360a59b4"
+  integrity sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
@@ -239,20 +287,10 @@
   resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.0.tgz#a0fb08403ced8c2a68d1d0acee926bd20be922f2"
   integrity sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==
 
-"@codemirror/theme-one-dark@^6.1.1":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.1.tgz#76600555cbb314c495216f018f75b0c28daff158"
-  integrity sha512-+CfzmScfJuD6uDF5bHJkAjWTQ2QAAHxODCPxUEgcImDYcJLT+4l5vLnBHmDVv46kCC5uUJGMrBJct2Z6JbvqyQ==
-  dependencies:
-    "@codemirror/language" "^6.0.0"
-    "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.0.0"
-    "@lezer/highlight" "^1.0.0"
-
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.2.2", "@codemirror/view@^6.6.0", "@codemirror/view@^6.9.1":
-  version "6.9.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.9.1.tgz#2ce4c528974b6172a5a4a738b7b0a0f04a4c1140"
-  integrity sha512-bzfSjJn9dAADVpabLKWKNmMG4ibyTV2e3eOGowjElNPTdTkSbi6ixPYHm2u0ADcETfKsi2/R84Rkmi91dH9yEg==
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.10.0", "@codemirror/view@^6.2.2", "@codemirror/view@^6.6.0":
+  version "6.10.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.10.0.tgz#40bb39f391955db8960337a9e80fd7564f8915e2"
+  integrity sha512-Oea3rvE4JQLMmLsy2b54yxXQJgJM9xKpUQIpF/LGgKUTH2lA06GAmEtKKWn5OUnbW3jrH1hHeUd8DJEgePMOeQ==
   dependencies:
     "@codemirror/state" "^6.1.4"
     style-mod "^4.0.0"
@@ -275,7 +313,7 @@
   resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.8.0.tgz#2e418b814d7451c40365b2dc4f88e9965ece0769"
   integrity sha512-wUkIWUx99Rj1vxRYQISxyzN0nplqu7t5sRDyJ8R3yNNkvALQAMC6Whj63qzCsZsymVFzC5up3y+ZVxaeh7b+xA==
 
-"@lezer/common@^1.0.0":
+"@lezer/common@^1.0.0", "@lezer/common@^1.0.2":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.2.tgz#8fb9b86bdaa2ece57e7d59e5ffbcb37d71815087"
   integrity sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==
@@ -297,16 +335,16 @@
     "@lezer/lr" "^1.0.0"
 
 "@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.3.tgz#bf5a36c2ee227f526d74997ac91f7777e29bd25d"
-  integrity sha512-3vLKLPThO4td43lYRBygmMY18JN3CPh9w+XS2j8WC30vR4yZeFG4z1iFe4jXE43NtGqe//zHW5q8ENLlHvz9gw==
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.4.tgz#98ed821e89f72981b7ba590474e6ee86c8185619"
+  integrity sha512-IECkFmw2l7sFcYXrV8iT9GeY4W0fU4CxX0WMwhmhMIVjoDdD1Hr6q3G2NqVtLg/yVe5n7i4menG3tJ2r4eCrPQ==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/html@^1.3.0":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.3.tgz#2eddae2ad000f9b184d9fc4686394d0fa0849993"
-  integrity sha512-04Fyvu66DjV2EjhDIG1kfDdktn5Pfw56SXPrzKNQH5B2m7BDfc6bDsz+ZJG8dLS3kIPEKbyyq1Sm2/kjeG0+AA==
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.4.tgz#7a5c5498dae6c93aee3de208bfb01aa3a0a932e3"
+  integrity sha512-HdJYMVZcT4YsMo7lW3ipL4NoyS2T67kMPuSVS5TgLGqmaCjEU/D6xv7zsa1ktvTK5lwk7zzF1e3eU6gBZIPm5g==
   dependencies:
     "@lezer/common" "^1.0.0"
     "@lezer/highlight" "^1.0.0"
@@ -321,9 +359,9 @@
     "@lezer/lr" "^1.0.0"
 
 "@lezer/javascript@^1.0.0":
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.1.tgz#97a15042c76b5979af6a069fac83cf6485628cbf"
-  integrity sha512-Hqx36DJeYhKtdpc7wBYPR0XF56ZzIp0IkMO/zNNj80xcaFOV4Oj/P7TQc/8k2TxNhzl7tV5tXS8ZOCPbT4L3nA==
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.3.tgz#f59e764a0578184c6fb86abb5279a9679777c3ba"
+  integrity sha512-k7Eo9z9B1supZ5cCD4ilQv/RZVN30eUQL+gGbr6ybrEY3avBAL5MDiYi2aa23Aj0A79ry4rJRvPAwE2TM8bd+A==
   dependencies:
     "@lezer/highlight" "^1.1.3"
     "@lezer/lr" "^1.3.0"
@@ -336,10 +374,10 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.3.tgz#0ac6c889f1235874f33c45a1b9785d7054f60708"
-  integrity sha512-JPQe3mwJlzEVqy67iQiiGozhcngbO8QBgpqZM6oL1Wj/dXckrEexpBLeFkq0edtW5IqnPRFxA24BHJni8Js69w==
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1":
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.4.tgz#8795bf2ba4f69b998e8fb4b5a7c57ea68753474c"
+  integrity sha512-7o+e4og/QoC/6btozDPJqnzBhUaD1fMfmvnEKQO1wRRiTse1WxaJ3OMEXZJnkgT6HCcTVOctSoXK9jGJw2oe9g==
   dependencies:
     "@lezer/common" "^1.0.0"
 
@@ -360,9 +398,9 @@
     "@lezer/lr" "^1.1.0"
 
 "@lezer/python@^1.0.0":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.2.tgz#1db9faf182ca04815b2c7e0f1ce37104b2564ec5"
-  integrity sha512-ukm4VhDasFX7/9BUYHTyUNXH0xQ5B7/QBlZD8P51+dh6GtXRSCQqNxloez5d+MxVb2Sg+31S8E/33qoFREfkpA==
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.4.tgz#6ef58ff965286150fea9f2db776944a1d69cd9b9"
+  integrity sha512-x82XgYxqqX0Yiw7uIemQJ3z2QyQme5BYpectkPfNg99OQrakqfwqVolqEVIrsj4QO9rVDLFZZ49J0Vbne7UbAA==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
@@ -375,6 +413,14 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
+"@lezer/sass@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.1.tgz#c0ec3ece28b04e92437a75ac4a806367e5cb6fd4"
+  integrity sha512-S/aYAzABzMqWLfKKqV89pCWME4yjZYC6xzD02l44wbmb0sHxmN9/8aE4GULrKFzFaGazHdXcGEbPZ4zzB6yqwQ==
+  dependencies:
+    "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.0.0"
+
 "@lezer/xml@^1.0.0":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.1.tgz#c4c738a407db610f0e9c59d0e9b16607cd029591"
@@ -2598,9 +2644,9 @@
     ansi-regex "^5.0.1"
 
 style-mod@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.0.tgz#97e7c2d68b592975f2ca7a63d0dd6fcacfe35a01"
-  integrity sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.3.tgz#136c4abc905f82a866a18b39df4dc08ec762b1ad"
+  integrity sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==
 
 supports-color@^5.3.0:
   version "5.5.0"
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 9a483f0..244002e 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -696,7 +696,6 @@
   name: string;
   email: EmailAddress;
   date: Timestamp;
-  tz: TimezoneOffset;
 }
 
 export type GroupId = BrandType<string, '_groupId'>;
@@ -1055,8 +1054,6 @@
 export type Timestamp = BrandType<string, '_timestamp'>;
 // The timezone offset from UTC in minutes
 
-export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
-
 export type TopicName = BrandType<string, '_topicName'>;
 
 export type TrackingId = BrandType<string, '_trackingId'>;
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index ad59edd..ef2f63e 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -10,6 +10,7 @@
   STARTED_AS_GUEST = 'Started as guest',
   VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
   VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  FOCUS = 'Focus changed',
   EXTENSION_DETECTED = 'Extension detected',
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
@@ -95,6 +96,12 @@
   LCP = 'LCP',
   // WebVitals - Interaction to Next Paint (INP): measures responsiveness
   INP = 'INP',
+  // Time to load preview for a user suggested edit or a fix from checks
+  PREVIEW_FIX_LOAD = 'PreviewFixLoad',
+  // Time to apply fix for a user suggested edit or a fix from checks
+  APPLY_FIX_LOAD = 'ApplyFixLoad',
+  // Time to copy target to clipboard
+  COPY_TO_CLIPBOARD = 'CopyToClipboard',
 }
 
 export enum Interaction {
@@ -127,4 +134,6 @@
   CHANGE_ACTION_FIRED = 'change-action-fired',
   BUTTON_CLICK = 'button-click',
   LINK_CLICK = 'link-click',
+  USER_ACTIVE = 'user-active',
+  USER_PASSIVE = 'user-passive',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 4d0d3f1..e15c240 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -39,8 +39,6 @@
 // The name that gets automatically input when a new reference is added.
 const NEW_NAME = 'refs/heads/*';
 const REFS_NAME = 'refs/';
-const ON_BEHALF_OF = '(On Behalf Of)';
-const LABEL = 'Label';
 
 @customElement('gr-access-section')
 export class GrAccessSection extends LitElement {
@@ -360,14 +358,14 @@
       labelOptions.push({
         id: 'label-' + labelName,
         value: {
-          name: `${LABEL} ${labelName}`,
+          name: `Label ${labelName}`,
           id: 'label-' + labelName,
         },
       });
       labelOptions.push({
         id: 'labelAs-' + labelName,
         value: {
-          name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+          name: `Label ${labelName} (On Behalf Of)`,
           id: 'labelAs-' + labelName,
         },
       });
@@ -384,11 +382,13 @@
     } else if (AccessPermissions[permission.id]) {
       return AccessPermissions[permission.id]?.name;
     } else if (permission.value.label) {
-      let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
-        behalfOf = ON_BEHALF_OF;
+        return `Label ${permission.value.label} (On Behalf Of)`;
+      } else if (permission.id.startsWith('removeLabel-')) {
+        return `Remove Label ${permission.value.label}`;
+      } else {
+        return `Label ${permission.value.label}`;
       }
-      return `${LABEL} ${permission.value.label}${behalfOf}`;
     }
     return undefined;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 593a1ed..2c397e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -355,7 +355,20 @@
 
       assert.equal(
         element.computePermissionName(permission),
-        'Label Code-Review(On Behalf Of)'
+        'Label Code-Review (On Behalf Of)'
+      );
+
+      permission = {
+        id: 'removeLabel-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Remove Label Code-Review'
       );
     });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 893a997..b30619e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -61,7 +61,7 @@
   override connectedCallback() {
     super.connectedCallback();
     this.getCreateGroupCapability();
-    fireTitleChange(this, 'Groups');
+    fireTitleChange('Groups');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index e0c0d30..f4a7bf3 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -40,7 +40,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Audit Log');
+    fireTitleChange('Audit Log');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index f8d9934..cffde7a 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -129,7 +129,7 @@
     super.connectedCallback();
     this.loadGroupDetails();
 
-    fireTitleChange(this, 'Members');
+    fireTitleChange('Members');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 1ec83efa8..223a700 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -342,7 +342,7 @@
     this.groupConfig = config;
     this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
 
-    fireTitleChange(this, config.name);
+    fireTitleChange(config.name);
 
     await Promise.all(promises);
     this.loading = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index a2de840..cb17de7 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -47,7 +47,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Plugins');
+    fireTitleChange('Plugins');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 11cfaab..553de0e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -91,7 +91,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Repo Commands');
+    fireTitleChange('Repo Commands');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index 7138f4e..391a22a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -24,7 +24,6 @@
   RepoName,
   TagInfo,
   Timestamp,
-  TimezoneOffset,
 } from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -73,7 +72,6 @@
       name: 'Test User',
       email: 'test.user@gmail.com' as EmailAddress,
       date: '2017-09-19 14:54:00.000000000' as Timestamp,
-      tz: 540 as TimezoneOffset,
     },
   };
 }
@@ -2202,7 +2200,6 @@
           name: 'Test User',
           email: 'test.user@gmail.com' as EmailAddress,
           date: '2017-09-19 14:54:00.000000000' as Timestamp,
-          tz: 540 as TimezoneOffset,
         };
 
         assert.deepEqual((element.items as TagInfo[])![2].tagger, tagger);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 4837f1a..5318500 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -59,7 +59,7 @@
   override async connectedCallback() {
     super.connectedCallback();
     await this.getCreateRepoCapability();
-    fireTitleChange(this, 'Repos');
+    fireTitleChange('Repos');
     this.maybeOpenCreateModal(this.params);
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 3599224..4bbd533 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -134,7 +134,7 @@
   override connectedCallback() {
     super.connectedCallback();
 
-    fireTitleChange(this, `${this.repo}`);
+    fireTitleChange(`${this.repo}`);
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index df63780..2ca7f36 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -147,7 +147,7 @@
   private handleClose() {
     this.actionModal.close();
     fireAlert(this, 'Reloading page..');
-    fireReload(this, true);
+    fireReload(this);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index db82523..6d7045a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -308,7 +308,7 @@
     this.actionModal.close();
     if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
       return;
-    fireReload(this, true);
+    fireReload(this);
   }
 
   private async handleConfirm() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 8a6f1c6..e656404 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -75,9 +75,16 @@
 
 @customElement('gr-change-list-item')
 export class GrChangeListItem extends LitElement {
-  /** The logged-in user's account, or null if no user is logged in. */
+  /** The logged-in user's account, or undefined if no user is logged in. */
   @property({type: Object})
-  account: AccountInfo | null = null;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: Array})
   visibleChangeTableColumns?: string[];
@@ -764,9 +771,9 @@
         !isServiceUser(r)
     );
     reviewers.sort((r1, r2) => {
-      if (this.account) {
-        if (isSelf(r1, this.account)) return -1;
-        if (isSelf(r2, this.account)) return 1;
+      if (this.loggedInUser) {
+        if (isSelf(r1, this.loggedInUser)) return -1;
+        if (isSelf(r2, this.loggedInUser)) return 1;
       }
       if (this.hasAttention(r1) && !this.hasAttention(r2)) return -1;
       if (this.hasAttention(r2) && !this.hasAttention(r1)) return 1;
@@ -825,9 +832,15 @@
   }
 
   private computeWaiting(): Timestamp | undefined {
-    if (!this.account?._account_id || !this.change?.attention_set)
-      return undefined;
-    return this.change?.attention_set[this.account._account_id]?.last_update;
+    // TODO: dashboardUser comes from DashboardViewState and can be an
+    // Email Address. In this case the attention_set lookup will return
+    // undefined.
+    const userId =
+      this.dashboardUser === 'self'
+        ? this.loggedInUser?._account_id
+        : this.dashboardUser;
+    if (!userId || !this.change?.attention_set) return undefined;
+    return this.change?.attention_set[userId]?.last_update;
   }
 
   private computeIsColumnHidden(
@@ -849,7 +862,7 @@
     // Don't prevent the default and neither stop bubbling. We just want to
     // report the click, but then let the browser handle the click on the link.
 
-    const selfId = (this.account && this.account._account_id) || -1;
+    const selfId = (this.loggedInUser && this.loggedInUser._account_id) || -1;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 5e31cc8..25e97af 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -243,7 +243,9 @@
     attSetIds: number[],
     expected: number[]
   ) {
-    element.account = userId ? {_account_id: userId as AccountId} : null;
+    element.loggedInUser = userId
+      ? {_account_id: userId as AccountId}
+      : undefined;
     element.change = {
       ...change,
       owner: {
@@ -384,7 +386,7 @@
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
     element.showNumber = true;
-    element.account = createAccountWithId(1);
+    element.loggedInUser = createAccountWithId(1);
     element.config = createServerInfo();
     element.change = change;
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 1248a0f..5e99df2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -75,7 +75,14 @@
    * in.
    */
   @property({type: Object})
-  account: AccountInfo | undefined = undefined;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: String})
   usp?: string;
@@ -319,7 +326,8 @@
     return html`
       <gr-change-list-item
         tabindex="0"
-        .account=${this.account}
+        .loggedInUser=${this.loggedInUser}
+        .dashboardUser=${this.dashboardUser}
         .selected=${selected}
         .change=${change}
         .sectionName=${this.changeSection.name}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 63552c7..4ec8491 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -56,7 +56,7 @@
     };
     element = await fixture<GrChangeListSection>(
       html`<gr-change-list-section
-        .account=${createAccountDetailWithId(1)}
+        .loggedInUser=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
         .visibleChangeTableColumns=${Object.values(ColumnNames)}
         .changeSection=${changeSection}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 8afc283..dbca42f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -32,12 +32,6 @@
 
 @customElement('gr-change-list-view')
 export class GrChangeListView extends LitElement {
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
   @query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
 
   @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
@@ -183,7 +177,7 @@
       <div ?hidden=${this.loading}>
         ${this.renderRepoHeader()} ${this.renderUserHeader()}
         <gr-change-list
-          .account=${this.account}
+          .loggedInUser=${this.account}
           .changes=${this.changes}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
@@ -248,7 +242,7 @@
 
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('query')) {
-      fireTitleChange(this, this.query);
+      fireTitleChange(this.query);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index b1f3c2c..38ee01a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -84,7 +84,14 @@
    * in.
    */
   @property({type: Object})
-  account: AccountInfo | undefined = undefined;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: Array})
   changes?: ChangeInfo[];
@@ -262,7 +269,8 @@
         .labelNames=${labelNames}
         .dynamicHeaderEndpoints=${this.dynamicHeaderEndpoints}
         .isCursorMoving=${this.isCursorMoving}
-        .account=${this.account}
+        .loggedInUser=${this.loggedInUser}
+        .dashboardUser=${this.dashboardUser}
         .selectedIndex=${computeRelativeIndex(
           this.selectedIndex,
           sectionIndex,
@@ -289,7 +297,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (
-      changedProperties.has('account') ||
+      changedProperties.has('loggedInUser') ||
       changedProperties.has('preferences') ||
       changedProperties.has('config') ||
       changedProperties.has('sections')
@@ -340,7 +348,7 @@
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
       this.isColumnEnabled(col, this.config)
     );
-    if (this.account && this.preferences) {
+    if (this.loggedInUser && this.preferences) {
       this.showNumber = !!this.preferences?.legacycid_in_change_table;
       if (
         this.preferences?.change_table &&
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index e201ab4..62a4f7d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -51,7 +51,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     element.sections = [
       {
@@ -106,7 +106,7 @@
   });
 
   test('show change number disabled when not logged in', async () => {
-    element.account = undefined;
+    element.loggedInUser = undefined;
     element.preferences = undefined;
     element.config = createServerInfo();
     await element.updateComplete;
@@ -120,7 +120,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     await element.updateComplete;
 
@@ -133,7 +133,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     await element.updateComplete;
 
@@ -415,7 +415,7 @@
       stubFlags('isEnabled').returns(true);
       element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
-      element.account = {_account_id: 1001 as AccountId};
+      element.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -447,7 +447,7 @@
       stubFlags('isEnabled').returns(true);
       element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
-      element.account = {_account_id: 1001 as AccountId};
+      element.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -486,7 +486,7 @@
       stubFlags('isEnabled').returns(true);
       element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
-      element.account = {_account_id: 1001 as AccountId};
+      element.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -537,7 +537,7 @@
 
   test('loggedIn and showNumber', async () => {
     element.sections = [{results: [{...createChange()}], name: 'a'}];
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       legacycid_in_change_table: false, // sets showNumber false
       time_format: TimeFormat.HHMM_12,
@@ -586,7 +586,7 @@
 
   test('garbage columns in preference are not shown', async () => {
     // This would only exist if somebody manually updated the config file.
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       legacycid_in_change_table: true,
       time_format: TimeFormat.HHMM_12,
@@ -603,7 +603,7 @@
       html`<gr-change-list></gr-change-list>`
     );
     element.sections = [{results: [{...createChange()}]}];
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       change_table: [
         'Status', // old status
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 70f1755..f4c5215 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -68,12 +68,6 @@
 
 @customElement('gr-dashboard-view')
 export class GrDashboardView extends LitElement {
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
   @query('#confirmDeleteDialog') protected confirmDeleteDialog?: GrDialog;
 
   @query('#commandsDialog') protected commandsDialog?: GrCreateCommandsDialog;
@@ -85,7 +79,7 @@
   protected confirmDeleteModal?: HTMLDialogElement;
 
   @property({type: Object})
-  account?: AccountDetailInfo;
+  loggedInUser?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
@@ -132,7 +126,7 @@
     subscribe(
       this,
       () => this.getUserModel().account$,
-      x => (this.account = x)
+      x => (this.loggedInUser = x)
     );
     subscribe(
       this,
@@ -292,7 +286,8 @@
         ${this.renderUserHeader()}
         <h1 class="assistive-tech-only">Dashboard</h1>
         <gr-change-list
-          .account=${this.account}
+          .loggedInUser=${this.loggedInUser}
+          .dashboardUser=${this.viewState?.user}
           .preferences=${this.preferences}
           .sections=${this.results}
           .usp=${'dashboard'}
@@ -398,22 +393,23 @@
       : Promise.resolve(
           getUserDashboard(user, sections, title || this.computeTitle(user))
         );
-    // Checking `this.account` to make sure that the user is logged in.
+    // Checking `this.loggedInUser` to make sure that the user is logged in.
     // Otherwise sending a query for 'owner:self' will result in an error.
-    const checkForNewUser = !project && !!this.account && user === 'self';
+    const isLoggedInUserDashboard =
+      !project && !!this.loggedInUser && user === 'self';
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
-          fireTitleChange(this, res.title);
+          fireTitleChange(res.title);
         }
-        return this.fetchDashboardChanges(res, checkForNewUser);
+        return this.fetchDashboardChanges(res, isLoggedInUserDashboard);
       })
       .then(() => {
         this.maybeShowDraftsBanner();
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        fireTitleChange(this, title || this.computeTitle(user));
+        fireTitleChange(title || this.computeTitle(user));
         this.reporting.error('Dashboard reload', err);
       })
       .finally(() => {
@@ -429,7 +425,7 @@
    */
   fetchDashboardChanges(
     res: UserDashboard | undefined,
-    checkForNewUser: boolean
+    isLoggedInUserDashboard: boolean
   ): Promise<void> {
     if (!res) {
       return Promise.resolve();
@@ -448,7 +444,8 @@
           : section.query
       );
 
-      if (checkForNewUser) {
+      if (isLoggedInUserDashboard) {
+        // The query to check if the user created any changes yet.
         queries.push('owner:self limit:1');
       }
     }
@@ -459,8 +456,9 @@
         if (!changes) {
           throw new Error('getChanges returns undefined');
         }
-        if (checkForNewUser) {
-          // Last set of results is not meant for dashboard display.
+        if (isLoggedInUserDashboard) {
+          // Last query ('owner:self limit:1') is only for evaluation if
+          // the user is "New" ie. haven't created any changes yet.
           const lastResultSet = changes.pop();
           this.showNewUserHelp = lastResultSet!.length === 0;
         }
@@ -491,7 +489,12 @@
    * And then we want to emphasize the changes where the waiting time is larger.
    */
   private maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account?._account_id;
+    // TODO: viewState?.user can be an Email Address. In this case the
+    // attention_set lookups will return undefined.
+    const userId =
+      this.viewState?.user === 'self'
+        ? this.loggedInUser?._account_id
+        : this.viewState?.user;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 84a3139..b05d970 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -319,7 +319,7 @@
 
   suite('selfOnly sections', () => {
     test('viewing self dashboard includes selfOnly sections', async () => {
-      element.account = undefined;
+      element.loggedInUser = undefined;
       element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
@@ -334,7 +334,7 @@
     });
 
     test('viewing dashboard when logged in includes owner:self query', async () => {
-      element.account = createAccountDetailWithId(1);
+      element.loggedInUser = createAccountDetailWithId(1);
       element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index edd7f3d..e0c28bc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -42,7 +42,6 @@
   BranchName,
   ChangeActionDialog,
   ChangeInfo,
-  ChangeViewChangeInfo,
   CherryPickInput,
   CommitId,
   InheritedBooleanInfo,
@@ -81,7 +80,6 @@
   fireAlert,
   fireError,
   fireNoBubbleNoCompose,
-  fireReload,
 } from '../../../utils/event-util';
 import {
   getApprovalInfo,
@@ -101,9 +99,9 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
-import {assertIsDefined, queryAll} from '../../../utils/common-util';
+import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
 import {Interaction} from '../../../constants/reporting';
 import {rootUrl} from '../../../utils/url-util';
 import {createSearchUrl} from '../../../models/views/search';
@@ -114,6 +112,9 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -378,82 +379,54 @@
 
   RevisionActions = RevisionActions;
 
-  @property({type: Object})
-  change?: ChangeViewChangeInfo;
+  @state() change?: ParsedChangeInfo;
 
-  @state()
-  actions: ActionNameToActionInfoMap = {};
+  @state() actions: ActionNameToActionInfoMap = {};
 
-  @property({type: Array})
-  primaryActionKeys: PrimaryActionKey[] = [
+  @state() primaryActionKeys: PrimaryActionKey[] = [
     ChangeActions.READY,
     RevisionActions.SUBMIT,
   ];
 
-  @property({type: Boolean})
-  disableEdit = false;
-
-  @property({type: Boolean})
-  _hasKnownChainState = false;
-
-  // private but used in test
   @state() _hideQuickApproveAction = false;
 
-  @property({type: Object})
-  account?: AccountInfo;
+  @state() account?: AccountInfo;
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  @property({type: String})
-  changeStatus?: ChangeStatus;
+  @state() changeStatus?: ChangeStatus;
 
-  @property({type: String})
-  commitNum?: CommitId;
-
-  @property({type: Boolean})
-  hasParent?: boolean;
+  @state() commitNum?: CommitId;
 
   @state() latestPatchNum?: PatchSetNumber;
 
-  @property({type: String})
-  commitMessage = '';
+  @state() commitMessage = '';
 
-  @property({type: Object})
-  revisionActions: ActionNameToActionInfoMap = {};
+  @state() revisionActions: ActionNameToActionInfoMap = {};
 
-  @state() private revisionSubmitAction?: ActionInfo | null;
+  @state() revisionSubmitAction?: ActionInfo | null;
 
-  // used as a proprty type so cannot be private
   @state() revisionRebaseAction?: ActionInfo | null;
 
-  @property({type: String})
-  privateByDefault?: InheritedBooleanInfo;
+  @state() privateByDefault?: InheritedBooleanInfo;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() actionLoadingMessage = '';
 
-  @state() private inProgressActionKeys = new Set<string>();
+  @state() inProgressActionKeys = new Set<string>();
 
-  // _computeAllActions always returns an array
-  // private but used in test
   @state() allActionValues: UIActionInfo[] = [];
 
-  // private but used in test
   @state() topLevelActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @state() private menuActions?: MenuAction[];
+  @state() menuActions?: MenuAction[];
 
-  @state() private overflowActions: OverflowAction[] = [
+  @state() overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -500,29 +473,21 @@
     },
   ];
 
-  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @state() private additionalActions: UIActionInfo[] = [];
+  @state() additionalActions: UIActionInfo[] = [];
 
-  // private but used in test
   @state() hiddenActions: string[] = [];
 
-  // private but used in test
   @state() disabledMenuActions: string[] = [];
 
-  // private but used in test
-  @state()
-  editPatchsetLoaded = false;
+  @state() editPatchsetLoaded = false;
 
-  @property({type: Boolean})
-  editMode = false;
+  @state() editMode = false;
 
-  // private but used in test
-  @state()
-  editBasedOnCurrentPatchSet = true;
+  @state() editBasedOnCurrentPatchSet = true;
 
-  @property({type: Boolean})
-  loggedIn = false;
+  @state() loggedIn = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -530,6 +495,10 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getStorage = resolve(this, storageServiceToken);
@@ -553,6 +522,51 @@
       () => this.getChangeModel().patchNum$,
       x => (this.editPatchsetLoaded = x === 'edit')
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().status$,
+      x => (this.changeStatus = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      x => (this.editMode = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      rev => (this.commitNum = rev?.commit?.commit)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestRevision$,
+      rev => (this.commitMessage = rev?.commit?.message ?? '')
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      config => (this.privateByDefault = config?.private_by_default)
+    );
   }
 
   override connectedCallback() {
@@ -678,14 +692,12 @@
         <gr-confirm-rebase-dialog
           id="confirmRebase"
           class="confirmDialog"
-          .changeNumber=${this.change?._number}
           @confirm-rebase=${this.handleRebaseConfirm}
           @cancel=${this.handleConfirmDialogCancel}
           .disableActions=${this.inProgressActionKeys.has(
             RevisionActions.REBASE
           )}
           .branch=${this.change?.branch}
-          .hasParent=${this.hasParent}
           .rebaseOnCurrent=${this.revisionRebaseAction
             ? !!this.revisionRebaseAction.enabled
             : null}
@@ -810,10 +822,6 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('hasParent')) {
-      this.computeChainState();
-    }
-
     if (changedProperties.has('change')) {
       this.reload();
       this.actions = this.change?.actions ?? {};
@@ -878,7 +886,7 @@
 
         this.revisionActions = revisionActions;
         this.sendShowRevisionActions({
-          change,
+          change: change as ChangeInfo,
           revisionActions,
         });
         this.handleLoadingComplete();
@@ -909,8 +917,7 @@
       enabled: true,
       label,
       __type: type,
-      __key:
-        ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+      __key: ADDITIONAL_ACTION_KEY_PREFIX + uuid(),
     };
     this.additionalActions.push(action);
     this.requestUpdate('additionalActions');
@@ -1045,18 +1052,7 @@
   }
 
   private editStatusChanged() {
-    // Hide change edits if not logged in
-    if (this.change === undefined || !this.loggedIn) {
-      return;
-    }
-    if (this.disableEdit) {
-      delete this.actions.rebaseEdit;
-      delete this.actions.publishEdit;
-      delete this.actions.deleteEdit;
-      delete this.actions.stopEdit;
-      delete this.actions.edit;
-      return;
-    }
+    if (!this.change || !this.loggedIn) return;
     if (this.editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
@@ -1135,7 +1131,7 @@
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
-    if (this.change && this.change.status === ChangeStatus.MERGED) {
+    if (this.change?.status === ChangeStatus.MERGED) {
       return null;
     }
     let result;
@@ -1337,18 +1333,18 @@
 
   // private but used in test
   canSubmitChange() {
-    if (!this.change) {
-      return false;
-    }
+    if (!this.change) return false;
+    const change = this.change as ChangeInfo;
+    const revision = this.getRevision(change, this.latestPatchNum);
     return this.getPluginLoader().jsApiService.canSubmitChange(
-      this.change,
-      this.getRevision(this.change, this.latestPatchNum)
+      change,
+      revision
     );
   }
 
   // private but used in test
-  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNumber) {
-    for (const rev of Object.values(change.revisions)) {
+  getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) {
+    for (const rev of Object.values(change.revisions ?? {})) {
       if (rev._number === patchNum) {
         return rev;
       }
@@ -1544,22 +1540,10 @@
     return key === '/' ? key : `/${key}`;
   }
 
-  /**
-   * _hasKnownChainState set to true true if hasParent is defined (can be
-   * either true or false). set to false otherwise.
-   *
-   * private but used in test
-   */
-  computeChainState() {
-    this._hasKnownChainState = true;
-  }
-
-  // private but used in test
-  calculateDisabled(action: UIActionInfo) {
-    if (action.__key === 'rebase') {
-      // Rebase button is only disabled when change has no parent(s).
-      return this._hasKnownChainState === false;
-    }
+  private calculateDisabled(action: UIActionInfo) {
+    // TODO(b/270972983): Remove this special casing once the backend is more
+    // aggressive about setting`enabled:true`.
+    if (action.__key === 'rebase') return false;
     return !action.enabled;
   }
 
@@ -1831,7 +1815,7 @@
     if (dialog.init) dialog.init();
     dialog.hidden = false;
     assertIsDefined(this.actionsModal, 'actionsModal');
-    this.actionsModal.showModal();
+    if (this.actionsModal.isConnected) this.actionsModal.showModal();
     whenVisible(dialog, () => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
@@ -1844,7 +1828,7 @@
   // private but used in test
   setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
-      this.change
+      this.change as ChangeInfo
     );
     if (!review) {
       return Promise.resolve(undefined);
@@ -1894,7 +1878,7 @@
           // Hide rebase dialog only if the action succeeds
           this.actionsModal?.close();
           this.hideAllDialogs();
-          fireReload(this, true);
+          this.getChangeModel().navigateToChangeResetReload();
           break;
         case ChangeActions.REVERT_SUBMISSION: {
           const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
@@ -1910,7 +1894,7 @@
           break;
         }
         default:
-          fireReload(this, true);
+          this.getChangeModel().navigateToChangeResetReload();
           break;
       }
     });
@@ -1978,7 +1962,7 @@
               'Cannot set label: a newer patch has been ' +
               'uploaded to this change.',
             action: 'Reload',
-            callback: () => fireReload(this, true),
+            callback: () => this.getChangeModel().navigateToChangeResetReload(),
           });
 
           // Because this is not a network error, call the cleanup function
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 277eddd..b628cc1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -40,7 +40,7 @@
   TopicName,
 } from '../../../types/common';
 import {ActionType} from '../../../api/change-actions';
-import {SinonFakeTimers} from 'sinon';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
@@ -56,10 +56,17 @@
 import {testResolver} from '../../../test/common-test-setup';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  ChangeModel,
+  changeModelToken,
+} from '../../../models/change/change-model';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
   let element: GrChangeActions;
+  let navigateResetStub: SinonStubbedMember<
+    ChangeModel['navigateToChangeResetReload']
+  >;
 
   suite('basic tests', () => {
     setup(async () => {
@@ -122,6 +129,7 @@
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
+      element.changeStatus = ChangeStatus.NEW;
       element.change = {
         ...createChangeViewChange(),
         actions: {
@@ -139,12 +147,16 @@
         _account_id: 123 as AccountId,
       };
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+      navigateResetStub = sinon.stub(
+        testResolver(changeModelToken),
+        'navigateToChangeResetReload'
+      );
 
       await element.updateComplete;
       await element.reload();
     });
 
-    test('render', () => {
+    test('render', async () => {
       assert.shadowDom.equal(
         element,
         /* HTML */ `
@@ -177,19 +189,36 @@
                 title="Rebase onto tip of branch or parent change"
               >
                 <gr-button
-                  aria-disabled="true"
+                  aria-disabled="false"
                   class="rebase"
                   data-action-key="rebase"
                   data-label="Rebase"
-                  disabled=""
                   link=""
                   role="button"
-                  tabindex="-1"
+                  tabindex="0"
                 >
                   <gr-icon icon="rebase"> </gr-icon>
                   Rebase
                 </gr-button>
               </gr-tooltip-content>
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Edit this change"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="edit"
+                  data-action-key="edit"
+                  data-label="Edit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  <gr-icon filled="" icon="edit"> </gr-icon>
+                  Edit
+                </gr-button>
+              </gr-tooltip-content>
             </section>
             <gr-button
               aria-disabled="false"
@@ -570,34 +599,6 @@
       assert.equal(fireActionStub.callCount, 0);
     });
 
-    test('chain state', async () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      await element.updateComplete;
-      assert.equal(element._hasKnownChainState, true);
-    });
-
-    test('calculateDisabled', () => {
-      const action = {
-        __key: 'rebase',
-        enabled: true,
-        __type: ActionType.CHANGE,
-        label: 'l',
-      };
-      element._hasKnownChainState = false;
-      assert.equal(element.calculateDisabled(action), true);
-
-      action.__key = 'delete';
-      assert.equal(element.calculateDisabled(action), false);
-
-      action.__key = 'rebase';
-      element._hasKnownChainState = true;
-      assert.equal(element.calculateDisabled(action), false);
-
-      action.enabled = false;
-      assert.equal(element.calculateDisabled(action), false);
-    });
-
     test('rebase change', async () => {
       const fireActionStub = sinon.stub(element, 'fireAction');
       const fetchChangesStub = sinon
@@ -606,7 +607,6 @@
           'fetchRecentChanges'
         )
         .returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
       await element.updateComplete;
       queryAndAssert<GrButton>(
         element,
@@ -642,13 +642,11 @@
     });
 
     test('rebase change fires reload event', async () => {
-      const eventStub = sinon.stub(element, 'dispatchEvent');
       await element.handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      assert.isTrue(eventStub.called);
-      assert.equal(eventStub.lastCall.args[0].type, 'reload');
+      assert.isTrue(navigateResetStub.called);
     });
 
     test("rebase dialog gets recent changes each time it's opened", async () => {
@@ -658,7 +656,6 @@
           'fetchRecentChanges'
         )
         .returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
       await element.updateComplete;
       const rebaseButton = queryAndAssert<GrButton>(
         element,
@@ -683,7 +680,6 @@
     });
 
     test('two dialogs are not shown at the same time', async () => {
-      element._hasKnownChainState = true;
       await element.updateComplete;
       queryAndAssert<GrButton>(
         element,
@@ -728,29 +724,6 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', async () => {
-        element.editMode = false;
-        element.editBasedOnCurrentPatchSet = false;
-        element.change = {
-          ...createChangeViewChange(),
-          status: ChangeStatus.NEW,
-        };
-        element.disableEdit = true;
-        await element.updateComplete;
-
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="publishEdit"]')
-        );
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="rebaseEdit"]')
-        );
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="deleteEdit"]')
-        );
-        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
-        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
-      });
-
       test('shows confirm dialog for delete edit', async () => {
         element.loggedIn = true;
         element.editMode = true;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 54752b0..1cb25ea 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -82,6 +82,12 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {getChangeWeblinks} from '../../../utils/weblink-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -118,53 +124,90 @@
 export class GrChangeMetadata extends LitElement {
   @query('#webLinks') webLinks?: HTMLElement;
 
-  @property({type: Object}) change?: ParsedChangeInfo;
-
-  @property({type: Object}) revertedChange?: ChangeInfo;
-
-  @property({type: Object}) account?: AccountDetailInfo;
-
-  @property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
-
-  @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
-
-  @property({type: Object}) serverConfig?: ServerInfo;
-
+  // TODO: Convert to @state. That requires the change model to keep track of
+  // current revision actions. Then we can also get rid of the
+  // `revision-actions-changed` event.
   @property({type: Boolean}) parentIsCurrent?: boolean;
 
-  @property({type: Object}) repoConfig?: ConfigInfo;
+  @state() change?: ParsedChangeInfo;
 
-  // private but used in test
+  @state() revertedChange?: ChangeInfo;
+
+  @state() account?: AccountDetailInfo;
+
+  @state() revision?: RevisionInfo | EditRevisionInfo;
+
+  @state() serverConfig?: ServerInfo;
+
+  @state() repoConfig?: ConfigInfo;
+
   @state() mutable = false;
 
-  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
+  @state() readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
 
-  // private but used in test
   @state() topicReadOnly = true;
 
-  // private but used in test
   @state() hashtagReadOnly = true;
 
-  @state() private pushCertificateValidation?: PushCertificateValidationInfo;
+  @state() pushCertificateValidation?: PushCertificateValidationInfo;
 
-  // private but used in test
   @state() settingTopic = false;
 
-  // private but used in test
   @state() currentParents: ParentCommitInfo[] = [];
 
-  @state() private showAllSections = false;
+  @state() showAllSections = false;
 
-  @state() private queryTopic?: AutocompleteQuery;
+  @state() queryTopic?: AutocompleteQuery;
 
-  @state() private queryHashtag?: AutocompleteQuery;
+  @state() queryHashtag?: AutocompleteQuery;
 
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      repoConfig => (this.repoConfig = repoConfig)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => (this.account = account)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      revision => (this.revision = revision)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().revertingChange$,
+      revertingChange => (this.revertedChange = revertingChange)
+    );
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
@@ -734,7 +777,10 @@
 
   // private but used in test
   computeWebLinks(): WebLinkInfo[] {
-    return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
+    return getChangeWeblinks(
+      this.revision?.commit?.web_links,
+      this.serverConfig
+    );
   }
 
   private computeStrategy() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index c46fe24..e0e1364 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -9,7 +9,6 @@
 import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
-  createUserConfig,
   createParsedChange,
   createAccountWithId,
   createCommitInfoWithRequiredCommit,
@@ -61,18 +60,9 @@
   let element: GrChangeMetadata;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(
-      Promise.resolve({
-        ...createServerInfo(),
-        user: {
-          ...createUserConfig(),
-          anonymouscowardname: 'test coward name',
-        },
-      })
-    );
     element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
     element.change = createParsedChange();
+    element.account = undefined;
     await element.updateComplete;
   });
 
@@ -251,16 +241,22 @@
   });
 
   test('weblinks hidden when no weblinks', async () => {
-    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.revision = {
+      ...createRevision(),
+      commit: createCommitInfoWithRequiredCommit(),
+    };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
     assert.isNull(element.webLinks);
   });
 
   test('weblinks hidden when only gitiles weblink', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+      },
     };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
@@ -270,9 +266,12 @@
 
   test('weblinks hidden when sole weblink is set as primary', async () => {
     const browser = 'browser';
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+      },
     };
     element.serverConfig = {
       ...createServerInfo(),
@@ -286,9 +285,12 @@
   });
 
   test('weblinks are visible when other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
@@ -297,12 +299,15 @@
   });
 
   test('weblinks are visible when gitiles and other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [
-        {...createWebLinkInfo(), name: 'test', url: '#'},
-        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
-      ],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [
+          {...createWebLinkInfo(), name: 'test', url: '#'},
+          {...createWebLinkInfo(), name: 'gitiles', url: '#'},
+        ],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 2759830..f6a40a2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -63,6 +63,9 @@
 @customElement('gr-change-summary')
 export class GrChangeSummary extends LitElement {
   @state()
+  commentsLoading = true;
+
+  @state()
   commentThreads?: CommentThread[];
 
   @state()
@@ -151,7 +154,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       x => (this.commentThreads = x)
     );
     subscribe(
@@ -161,10 +164,15 @@
     );
     subscribe(
       this,
+      () => this.getCommentsModel().commentsLoading$,
+      x => (this.commentsLoading = x)
+    );
+    subscribe(
+      this,
       () =>
         combineLatest([
           this.getUserModel().account$,
-          this.getCommentsModel().threads$,
+          this.getCommentsModel().threadsSaved$,
         ]),
       ([selfAccount, threads]) => {
         if (!selfAccount || !selfAccount.email) return;
@@ -530,6 +538,10 @@
           <tr>
             <td class="key">Comments</td>
             <td class="value">
+              ${when(
+                this.commentsLoading,
+                () => html`<span class="loadingSpin"></span>`
+              )}
               <gr-comments-summary
                 .commentThreads=${this.commentThreads}
                 .draftCount=${this.draftCount}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 3cb63f9..c3d9774 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -50,6 +50,7 @@
       },
       discardedDrafts: [],
     });
+    element.commentsLoading = false;
     element.commentThreads = [
       createCommentThread([createComment()]),
       createCommentThread([{...createComment(), unresolved: true}]),
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 9acc910..02fdb34 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
@@ -34,27 +34,16 @@
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {whenVisible, windowLocationReload} from '../../../utils/dom-util';
+import {untilRendered, whenVisible} from '../../../utils/dom-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {
-  ChangeStatus,
-  DefaultBase,
-  Tab,
-  DiffViewMode,
-} from '../../../constants/constants';
+import {ChangeStatus, Tab, DiffViewMode} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  findEdit,
-  findEditParentRevision,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {
-  changeIsAbandoned,
-  changeIsMerged,
-  changeIsOpen,
   changeStatuses,
   isInvolved,
   roleDetails,
@@ -63,38 +52,27 @@
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
-import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
 import {
   AccountDetailInfo,
   ActionNameToActionInfoMap,
   BasePatchSetNum,
-  ChangeId,
   ChangeInfo,
   CommentThread,
-  CommitId,
-  CommitInfo,
   ConfigInfo,
   DetailedLabelInfo,
-  DraftInfo,
   EDIT,
   LabelNameToInfoMap,
   NumericChangeId,
   PARENT,
-  PatchRange,
   PatchSetNum,
-  PatchSetNumber,
-  PreferencesInfo,
   QuickLabelInfo,
-  RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
   RevisionInfo,
   RevisionPatchSetNum,
   ServerInfo,
   UrlEncodedCommentId,
   isRobot,
-  ChangeStates,
 } from '../../../types/common';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
@@ -128,16 +106,15 @@
   fireDialogChange,
   fire,
   fireReload,
-  fireTitleChange,
 } from '../../../utils/event-util';
 import {
   debounce,
   DelayedTask,
   throttleWrap,
   until,
+  waitUntil,
 } from '../../../utils/async-util';
-import {Interaction, Timing} from '../../../constants/reporting';
-import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
 import {
   getAddedByReason,
   getRemovedByReason,
@@ -153,7 +130,7 @@
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
 import {changeModelToken} from '../../../models/change/change-model';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {paperStyles} from '../../../styles/gr-paper-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -163,7 +140,6 @@
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
-import {filesModelToken} from '../../../models/change/files-model';
 import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
 import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
 import {
@@ -177,6 +153,7 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -200,17 +177,9 @@
 // Making the tab names more unique in case a plugin adds one with same name
 const ROBOT_COMMENTS_LIMIT = 10;
 
-export type ChangeViewPatchRange = Partial<PatchRange>;
-
 @customElement('gr-change-view')
 export class GrChangeView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired if an error occurs when fetching the change data.
    *
    * @event page-error
@@ -266,35 +235,18 @@
 
   @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
 
-  private _viewState?: ChangeViewState;
-
-  @property({type: Object})
-  get viewState() {
-    return this._viewState;
-  }
-
-  set viewState(viewState: ChangeViewState | undefined) {
-    if (this._viewState === viewState) return;
-    const oldViewState = this._viewState;
-    this._viewState = viewState;
-    this.viewStateChanged();
-    this.requestUpdate('viewState', oldViewState);
-  }
+  @state()
+  viewState?: ChangeViewState;
 
   @property({type: String})
   backPage?: string;
 
   @state()
-  private hasParent?: boolean;
-
-  // Private but used in tests.
-  @state()
   commentThreads?: CommentThread[];
 
   // Don't use, use serverConfig instead.
   private _serverConfig?: ServerInfo;
 
-  // Private but used in tests.
   @state()
   get serverConfig() {
     return this._serverConfig;
@@ -311,10 +263,6 @@
   @state()
   private account?: AccountDetailInfo;
 
-  // Private but used in tests.
-  @state()
-  prefs?: PreferencesInfo;
-
   canStartReview() {
     return !!(
       this.change &&
@@ -342,42 +290,19 @@
 
   // Private but used in tests.
   @state()
-  commitInfo?: CommitInfo;
-
-  // Private but used in tests.
-  @state()
   changeNum?: NumericChangeId;
 
-  // Private but used in tests.
-  @state()
-  diffDrafts?: {[path: string]: DraftInfo[]} = {};
-
   @state()
   private editingCommitMessage = false;
 
   @state()
-  private latestCommitMessage: string | null = '';
+  private latestCommitMessage = '';
 
-  // Use patchRange getter/setter.
-  private _patchRange?: ChangeViewPatchRange;
+  @state() basePatchNum: BasePatchSetNum = PARENT;
 
-  // Private but used in tests.
-  @state()
-  get patchRange() {
-    return this._patchRange;
-  }
+  @state() patchNum?: RevisionPatchSetNum;
 
-  set patchRange(patchRange: ChangeViewPatchRange | undefined) {
-    if (this._patchRange === patchRange) return;
-    const oldPatchRange = this._patchRange;
-    this._patchRange = patchRange;
-    this.patchNumChanged();
-    this.requestUpdate('patchRange', oldPatchRange);
-  }
-
-  // Private but used in tests.
-  @state()
-  selectedRevision?: RevisionInfo | EditRevisionInfo;
+  @state() revision?: RevisionInfo | EditRevisionInfo;
 
   /**
    * <gr-change-actions> populates this via two-way data binding.
@@ -405,31 +330,12 @@
 
   // Private but used in tests.
   @state()
-  initialLoadComplete = false;
-
-  // Private but used in tests.
-  @state()
   replyDisabled = true;
 
-  // Private but used in tests.
-  @state()
-  changeStatuses: ChangeStates[] = [];
-
   @state()
   private updateCheckTimerHandle?: number | null;
 
-  // Private but used in tests.
-  getEditMode() {
-    if (!this.patchRange || !this.viewState) {
-      return false;
-    }
-
-    if (this.viewState.edit) {
-      return true;
-    }
-
-    return this.patchRange.patchNum === EDIT;
-  }
+  @state() editMode = false;
 
   isSubmitEnabled(): boolean {
     return !!(
@@ -439,9 +345,7 @@
     );
   }
 
-  // Private but used in tests.
-  @state()
-  mergeable: boolean | null = null;
+  @state() mergeable?: boolean;
 
   /**
    * Plugins can provide (multiple) tabs. For each plugin tab we render an
@@ -490,7 +394,7 @@
   private showRobotCommentsButton = false;
 
   @state()
-  private draftCount = 0;
+  draftCount = 0;
 
   private throttledToggleChangeStar?: (e: KeyboardEvent) => void;
 
@@ -508,7 +412,7 @@
   private tabState?: TabState;
 
   @state()
-  private revertedChange?: ChangeInfo;
+  private revertingChange?: ChangeInfo;
 
   // Private but used in tests.
   @state()
@@ -535,10 +439,13 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private readonly getFilesModel = resolve(this, filesModelToken);
-
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private scrollTask?: DelayedTask;
@@ -586,14 +493,7 @@
       this.handleCommitMessageCancel()
     );
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
-
     this.addEventListener('show-tab', e => this.setActiveTab(e));
-    this.addEventListener('reload', e => {
-      this.loadData(
-        /* isLocationChange= */ false,
-        /* clearPatchset= */ e.detail && e.detail.clearPatchset
-      );
-    });
   }
 
   private setupShortcuts() {
@@ -601,7 +501,7 @@
     this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
-      fireReload(this, true)
+      this.getChangeModel().navigateToChangeResetReload()
     );
     this.shortcutsController.addAbstract(Shortcut.OPEN_REPLY_DIALOG, () =>
       this.handleOpenReplyDialog()
@@ -671,7 +571,21 @@
     subscribe(
       this,
       () => this.getViewModel().tab$,
-      t => (this.activeTab = t ?? Tab.FILES)
+      t => (this.activeTab = t)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().commentId$,
+      commentId => (this.scrollCommentId = commentId)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().openReplyDialog$,
+      openReplyDialog => {
+        // Here we are relying on `this.loggedIn` being set *before*
+        // `openReplyDialog`, but that is fine for this feature.
+        if (openReplyDialog && this.loggedIn) this.handleOpenReplyDialog();
+      }
     );
     subscribe(
       this,
@@ -692,6 +606,11 @@
       () => this.getViewModel().childView$,
       childView => {
         this.isViewCurrent = childView === ChangeChildView.OVERVIEW;
+        // When coming back from ChangeChildView.DIFF we want to restore the
+        // scroll position to what it was before leaving the OVERVIEW page.
+        if (this.isViewCurrent) {
+          document.documentElement.scrollTop = this.scrollPosition ?? 0;
+        }
       }
     );
     subscribe(
@@ -703,13 +622,6 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().drafts$,
-      drafts => {
-        this.diffDrafts = {...drafts};
-      }
-    );
-    subscribe(
-      this,
       () => this.getUserModel().preferenceDiffViewMode$,
       diffViewMode => {
         this.diffViewMode = diffViewMode;
@@ -724,7 +636,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       threads => {
         this.commentThreads = threads;
       }
@@ -740,6 +652,54 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => {
+        // The change view is tied to a specific change number, so don't update
+        // changeNum to undefined and only set it once.
+        if (changeNum && !this.changeNum) this.changeNum = changeNum;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      editMode => (this.editMode = editMode)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      patchNum => (this.patchNum = patchNum)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().basePatchNum$,
+      basePatchNum => (this.basePatchNum = basePatchNum)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().mergeable$,
+      mergeable => (this.mergeable = mergeable)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      revision => (this.revision = revision)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeLoadingStatus$,
+      status => (this.loading = status !== LoadingStatus.LOADED)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestRevision$,
+      revision => {
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+          revision?.commit?.message ?? ''
+        );
+      }
+    );
+    subscribe(
+      this,
       () => this.getUserModel().account$,
       account => {
         this.account = account;
@@ -767,6 +727,13 @@
         this.projectConfig = config;
       }
     );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().revertingChange$,
+      revertingChange => {
+        this.revertingChange = revertingChange;
+      }
+    );
   }
 
   override connectedCallback() {
@@ -781,6 +748,8 @@
   }
 
   override firstUpdated() {
+    this.maybeScrollToMessage(window.location.hash);
+    this.maybeShowRevertDialog();
     // _onTabSizingChanged is called when iron-items-changed event is fired
     // from iron-selectable but that is called before the element is present
     // in view which whereas the method requires paper tabs already be visible
@@ -819,8 +788,7 @@
             new Error('Mismatch of headers and content.')
           );
         }
-      })
-      .then(() => this.initActiveTab());
+      });
 
     this.throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
       this.handleToggleChangeStar()
@@ -842,16 +810,6 @@
     super.disconnectedCallback();
   }
 
-  protected override willUpdate(changedProperties: PropertyValues): void {
-    if (
-      changedProperties.has('change') ||
-      changedProperties.has('mergeable') ||
-      changedProperties.has('currentRevisionActions')
-    ) {
-      this.changeStatuses = this.computeChangeStatusChips();
-    }
-  }
-
   static override get styles() {
     return [
       a11yStyles,
@@ -1062,7 +1020,7 @@
           .relatedChanges {
             padding: 0;
           }
-          #relatedChanges {
+          .relatedChanges gr-related-changes-list {
             padding-top: var(--spacing-l);
           }
           #commitAndRelated {
@@ -1178,23 +1136,16 @@
         ${this.renderTabHeaders()} ${this.renderTabContent()}
         ${this.renderChangeLog()}
       </div>
-      <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      ></gr-apply-fix-dialog>
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
       </dialog>
       <dialog id="includedInModal" tabindex="-1">
         <gr-included-in-dialog
           id="includedInDialog"
-          .changeNum=${this.changeNum}
           @close=${this.handleIncludedInDialogClose}
         ></gr-included-in-dialog>
       </dialog>
@@ -1231,12 +1182,14 @@
   }
 
   private renderHeaderTitle() {
-    const resolveWeblinks = this.commitInfo?.resolve_conflicts_web_links ?? [];
+    const changeStatuses = this.computeChangeStatusChips();
+    const resolveWeblinks =
+      this.revision?.commit?.resolve_conflicts_web_links ?? [];
     return html` <div class="headerTitle">
       <div class="changeStatuses">
-        ${this.changeStatuses.map(
+        ${changeStatuses.map(
           status => html` <gr-change-status
-            .revertedChange=${this.revertedChange}
+            .revertedChange=${this.revertingChange}
             .status=${status}
             .resolveWeblinks=${resolveWeblinks}
           ></gr-change-status>`
@@ -1329,28 +1282,18 @@
   }
 
   private renderCommitActions() {
-    return html` <div class="commitActions">
-      <!-- always show gr-change-actions regardless if logged in or not -->
-      <gr-change-actions
-        id="actions"
-        .change=${this.change}
-        .disableEdit=${false}
-        .hasParent=${this.hasParent}
-        .account=${this.account}
-        .changeNum=${this.changeNum}
-        .changeStatus=${this.change?.status}
-        .commitNum=${this.commitInfo?.commit}
-        .commitMessage=${this.latestCommitMessage}
-        .editMode=${this.getEditMode()}
-        .privateByDefault=${this.projectConfig?.private_by_default}
-        .loggedIn=${this.loggedIn}
-        @edit-tap=${() => this.handleEditTap()}
-        @stop-edit-tap=${() => this.handleStopEditTap()}
-        @download-tap=${() => this.handleOpenDownloadDialog()}
-        @included-tap=${() => this.handleOpenIncludedInDialog()}
-        @revision-actions-changed=${this.handleRevisionActionsChanged}
-      ></gr-change-actions>
-    </div>`;
+    return html`
+      <div class="commitActions">
+        <gr-change-actions
+          id="actions"
+          @edit-tap=${() => this.handleEditTap()}
+          @stop-edit-tap=${() => this.handleStopEditTap()}
+          @download-tap=${() => this.handleOpenDownloadDialog()}
+          @included-tap=${() => this.handleOpenIncludedInDialog()}
+          @revision-actions-changed=${this.handleRevisionActionsChanged}
+        ></gr-change-actions>
+      </div>
+    `;
   }
 
   private renderChangeInfo() {
@@ -1358,20 +1301,13 @@
       this.loggedIn,
       this.editingCommitMessage,
       this.change,
-      this.getEditMode()
+      this.editMode
     );
     return html` <div class="changeInfo">
       <div class="changeInfo-column changeMetadata">
         <gr-change-metadata
           id="metadata"
-          .change=${this.change}
-          .revertedChange=${this.revertedChange}
-          .account=${this.account}
-          .revision=${this.selectedRevision}
-          .commitInfo=${this.commitInfo}
-          .serverConfig=${this.serverConfig}
           .parentIsCurrent=${this.isParentCurrent()}
-          .repoConfig=${this.projectConfig}
           @show-reply-dialog=${this.handleShowReplyDialog}
         >
         </gr-change-metadata>
@@ -1408,7 +1344,7 @@
                 remove-zero-width-space=""
               >
                 <gr-formatted-text
-                  .content=${this.latestCommitMessage ?? ''}
+                  .content=${this.latestCommitMessage}
                   .markdown=${false}
                 ></gr-formatted-text>
               </gr-editable-content>
@@ -1418,18 +1354,12 @@
             <gr-endpoint-decorator name="commit-container">
               <gr-endpoint-param name="change" .value=${this.change}>
               </gr-endpoint-param>
-              <gr-endpoint-param
-                name="revision"
-                .value=${this.selectedRevision}
-              >
+              <gr-endpoint-param name="revision" .value=${this.revision}>
               </gr-endpoint-param>
             </gr-endpoint-decorator>
           </div>
           <div class="relatedChanges">
-            <gr-related-changes-list
-              id="relatedChanges"
-              .mergeable=${this.mergeable}
-            ></gr-related-changes-list>
+            <gr-related-changes-list></gr-related-changes-list>
           </div>
           <div class="emptySpace"></div>
         </div>
@@ -1468,14 +1398,11 @@
         )}
         ${this.pluginTabsHeaderEndpoints.map(
           tabHeader => html`
-            <paper-tab data-name=${tabHeader}>
+            <paper-tab data-name=${tabHeader} @click=${this.onPaperTabClick}>
               <gr-endpoint-decorator name=${tabHeader}>
                 <gr-endpoint-param name="change" .value=${this.change}>
                 </gr-endpoint-param>
-                <gr-endpoint-param
-                  name="revision"
-                  .value=${this.selectedRevision}
-                >
+                <gr-endpoint-param name="revision" .value=${this.revision}>
                 </gr-endpoint-param>
               </gr-endpoint-decorator>
             </paper-tab>
@@ -1511,9 +1438,9 @@
           .account=${this.account}
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .commitInfo=${this.commitInfo}
+          .commitInfo=${this.revision?.commit}
           .changeUrl=${this.computeChangeUrl()}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           .loggedIn=${this.loggedIn}
           .shownFileCount=${this.shownFileCount}
           .filesExpanded=${this.fileList?.filesExpanded}
@@ -1527,7 +1454,7 @@
           id="fileList"
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
             this.shownFileCount = e.detail.length;
           }}
@@ -1606,7 +1533,7 @@
       <gr-endpoint-decorator .name=${pluginTabContentEndpoint}>
         <gr-endpoint-param name="change" .value=${this.change}>
         </gr-endpoint-param>
-        <gr-endpoint-param name="revision" .value=${this.selectedRevision}></gr-endpoint-param>
+        <gr-endpoint-param name="revision" .value=${this.revision}></gr-endpoint-param>
         </gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
@@ -1617,7 +1544,7 @@
       <gr-endpoint-decorator name="change-view-integration">
         <gr-endpoint-param name="change" .value=${this.change}>
         </gr-endpoint-param>
-        <gr-endpoint-param name="revision" .value=${this.selectedRevision}>
+        <gr-endpoint-param name="revision" .value=${this.revision}>
         </gr-endpoint-param>
       </gr-endpoint-decorator>
 
@@ -1631,14 +1558,24 @@
         <gr-messages-list
           .labels=${this.change?.labels}
           .messages=${this.change?.messages}
-          .reviewerUpdates=${this.change?.reviewer_updates}
+          .reviewerUpdates=${this.change?.reviewer_updates ?? []}
           @message-anchor-tap=${this.handleMessageAnchorTap}
-          @reply=${this.handleMessageReply}
         ></gr-messages-list>
       </section>
     `;
   }
 
+  override updated() {
+    const tabs = [...queryAll<HTMLElement>(this.tabs!, 'paper-tab')];
+    const tabIndex = tabs.findIndex(t => t.dataset['name'] === this.activeTab);
+
+    if (tabIndex !== -1 && this.tabs!.selected !== tabIndex) {
+      this.tabs!.selected = tabIndex;
+    }
+    this.reportChangeDisplayed();
+    this.reportFullyLoaded();
+  }
+
   private readonly handleScroll = () => {
     if (!this.isViewCurrent) return;
     this.scrollTask = debounce(
@@ -1680,22 +1617,10 @@
   }
 
   setActiveTab(e: SwitchTabEvent) {
-    if (!this.tabs) return;
-    const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
-    if (!tabs) return;
-
     const tab = e.detail.tab;
-    const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab);
-    assert(tabIndex !== -1, `tab ${tab} not found`);
-
-    if (this.tabs.selected !== tabIndex) {
-      this.tabs.selected = tabIndex;
-    }
-
     this.getViewModel().updateState({tab});
-
     if (e.detail.tabState) this.tabState = e.detail.tabState;
-    if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'});
+    if (e.detail.scrollIntoView) this.tabs!.scrollIntoView({block: 'center'});
   }
 
   /**
@@ -1735,7 +1660,8 @@
   }
 
   private handleContentChanged(e: ValueChangedEvent) {
-    this.latestCommitMessage = e.detail.value;
+    // optimistic update
+    this.latestCommitMessage = e.detail.value ?? '';
   }
 
   // Private but used in tests.
@@ -1762,9 +1688,8 @@
           return;
         }
 
-        this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
         this.editingCommitMessage = false;
-        this.reloadWindow();
+        this.getChangeModel().navigateToChangeResetReload();
       })
       .catch(() => {
         assertIsDefined(this.commitMessageEditor);
@@ -1772,28 +1697,16 @@
       });
   }
 
-  private reloadWindow() {
-    windowLocationReload();
-  }
-
   private handleCommitMessageCancel() {
     this.editingCommitMessage = false;
   }
 
   private computeChangeStatusChips() {
-    if (!this.change) {
-      return [];
-    }
-
-    // Show no chips until mergeability is loaded.
-    if (this.mergeable === null) {
-      return [];
-    }
+    if (!this.change || this.mergeable === undefined) return [];
 
     const options = {
-      includeDerived: true,
-      mergeable: !!this.mergeable,
-      submitEnabled: !!this.isSubmitEnabled(),
+      mergeable: this.mergeable,
+      revertingChangeStatus: this.revertingChange?.status,
     };
     return changeStatuses(this.change as ChangeInfo, options);
   }
@@ -1960,28 +1873,10 @@
   }
 
   // Private but used in tests.
-  handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
-    const msg: string = e.detail.message.message;
-    const quoteStr =
-      msg
-        .split('\n')
-        .map(line => '> ' + line)
-        .join('\n') + '\n\n';
-    this.openReplyDialog(FocusTarget.BODY, quoteStr);
-  }
-
-  // Private but used in tests.
   handleReplySent() {
-    this.addEventListener(
-      'change-details-loaded',
-      () => {
-        this.reporting.timeEnd(Timing.SEND_REPLY);
-      },
-      {once: true}
-    );
     assertIsDefined(this.replyModal);
     this.replyModal.close();
-    fireReload(this);
+    this.getChangeModel().navigateToChangeResetReload();
   }
 
   private handleReplyCancel() {
@@ -2032,168 +1927,25 @@
   }
 
   // Private but used in tests.
-  hasPatchRangeChanged(viewState: ChangeViewState) {
-    if (!this.patchRange) return false;
-    if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
-    return this.hasPatchNumChanged(viewState);
-  }
-
-  // Private but used in tests.
-  hasPatchNumChanged(viewState: ChangeViewState) {
-    if (!this.patchRange) return false;
-    if (viewState.patchNum !== undefined) {
-      return this.patchRange.patchNum !== viewState.patchNum;
-    } else {
-      // value.patchNum === undefined specifies the latest patchset
-      return (
-        this.patchRange.patchNum !== computeLatestPatchNum(this.allPatchSets)
-      );
-    }
-  }
-
-  // Private but used in tests.
-  viewStateChanged() {
-    if (!this.viewState) return;
-    if (this.isChangeObsolete()) return;
-
-    if (this.viewState.basePatchNum === undefined)
-      this.viewState.basePatchNum = PARENT;
-
-    const patchChanged = this.hasPatchRangeChanged(this.viewState);
-
-    this.patchRange = {
-      patchNum: this.viewState.patchNum,
-      basePatchNum: this.viewState.basePatchNum,
-    };
-    this.scrollCommentId = this.viewState.commentId;
-
-    const patchKnown =
-      !this.patchRange.patchNum ||
-      (this.allPatchSets ?? []).some(
-        ps => ps.num === this.patchRange!.patchNum
-      );
-    // _allPatchsets does not know value.patchNum so force a reload.
-    const forceReload = this.viewState.forceReload || !patchKnown;
-
-    // If changeNum is defined that means the change has already been
-    // rendered once before so a full reload is not required.
-    if (this.changeNum !== undefined && !forceReload) {
-      if (!this.patchRange.patchNum) {
-        this.patchRange = {
-          ...this.patchRange,
-          patchNum: computeLatestPatchNum(this.allPatchSets),
-        };
-      }
-      if (patchChanged) {
-        // We need to collapse all diffs when viewState changes so that a non
-        // existing diff is not requested. See Issue 125270 for more details.
-        this.fileList?.resetFileState();
-        this.fileList?.collapseAllDiffs();
-        this.reloadPatchNumDependentResources();
-      }
-
-      // If there is no change in patchset or changeNum, such as when user goes
-      // to the diff view and then comes back to change page then there is no
-      // need to reload anything and we render the change view component as is.
-      document.documentElement.scrollTop = this.scrollPosition ?? 0;
-      this.reporting.reportInteraction('change-view-re-rendered');
-      this.updateTitle(this.change);
-      // We still need to check if post load tasks need to be done such as when
-      // user wants to open the reply dialog when in the diff page, the change
-      // page should open the reply dialog
-      this.performPostLoadTasks();
-      return;
-    }
-
-    // We need to collapse all diffs when viewState changes so that a non
-    // existing diff is not requested. See Issue 125270 for more details.
-    this.updateComplete.then(() => {
-      assertIsDefined(this.fileList);
-      this.fileList?.collapseAllDiffs();
-      this.fileList?.resetFileState();
-    });
-
-    // If the change was loaded before, then we are firing a 'reload' event
-    // instead of calling `loadData()` directly for two reasons:
-    // 1. We want to avoid code such as `this.initialLoadComplete = false` that
-    //    is only relevant for the initial load of a change.
-    // 2. We have to somehow trigger the change-model reloading. Otherwise
-    //    this.change is not updated.
-    if (this.changeNum) {
-      if (!this._patchRange?.patchNum) {
-        this._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: computeLatestPatchNum(this.allPatchSets),
-        };
-      }
-      fireReload(this);
-      return;
-    }
-
-    this.initialLoadComplete = false;
-    this.changeNum = this.viewState.changeNum;
-    this.loadData(true).then(() => {
-      this.performPostLoadTasks();
-    });
-
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        this.initActiveTab();
-      });
-  }
-
-  private initActiveTab() {
-    let tab = Tab.FILES;
-    if (this.viewState?.tab) {
-      tab = this.viewState?.tab as Tab;
-    } else if (this.viewState?.commentId) {
-      tab = Tab.COMMENT_THREADS;
-    }
-    this.setActiveTab(new CustomEvent('show-tab', {detail: {tab}}));
-  }
-
-  // Private but used in tests.
-  sendShowChangeEvent() {
-    assertIsDefined(this.patchRange, 'patchRange');
-    this.getPluginLoader().jsApiService.handleShowChange({
-      change: this.change,
-      patchNum: this.patchRange.patchNum,
-      info: {mergeable: this.mergeable},
-    });
-  }
-
-  private performPostLoadTasks() {
-    this.maybeShowReplyDialog();
-    this.maybeShowRevertDialog();
-
-    this.sendShowChangeEvent();
-
-    this.updateComplete.then(() => {
-      this.maybeScrollToMessage(window.location.hash);
-      this.initialLoadComplete = true;
-    });
-  }
-
-  // Private but used in tests.
   handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const hash = PREFIX + e.detail.id;
     const url = createChangeUrl({
       change: this.change,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
-      edit: this.getEditMode(),
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+      edit: this.editMode,
       messageHash: hash,
     });
     history.replaceState(null, '', url);
   }
 
   // Private but used in tests.
-  maybeScrollToMessage(hash: string) {
-    if (hash.startsWith(PREFIX) && this.messagesList) {
-      this.messagesList.scrollToMessage(hash.substr(PREFIX.length));
+  async maybeScrollToMessage(hash: string) {
+    if (hash.startsWith(PREFIX)) {
+      await waitUntil(() => !!this.messagesList);
+      await this.messagesList!.scrollToMessage(hash.substr(PREFIX.length));
     }
   }
 
@@ -2216,39 +1968,18 @@
   }
 
   // Private but used in tests.
-  maybeShowRevertDialog() {
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        if (
-          !this.loggedIn ||
-          !this.change ||
-          this.change.status !== ChangeStatus.MERGED
-        ) {
-          // Do not display dialog if not logged-in or the change is not
-          // merged.
-          return;
-        }
-        if (this._getUrlParameter('revert')) {
-          assertIsDefined(this.actions);
-          this.actions.showRevertDialog();
-        }
-      });
-  }
+  async maybeShowRevertDialog() {
+    if (!this._getUrlParameter('revert')) return;
 
-  private maybeShowReplyDialog() {
-    if (!this.loggedIn) return;
-    if (this.viewState?.openReplyDialog) {
-      this.openReplyDialog(FocusTarget.ANY);
+    await this.getPluginLoader().awaitPluginsLoaded();
+    await waitUntil(() => !!this.actions);
+    await waitUntil(() => !!this.change);
+
+    if (this.change?.status === ChangeStatus.MERGED && this.loggedIn) {
+      this.actions!.showRevertDialog();
     }
   }
 
-  private updateTitle(change?: ChangeInfo | ParsedChangeInfo) {
-    if (!change) return;
-    const title = `${change.subject} (${change._number})`;
-    fireTitleChange(this, title);
-  }
-
   // Private but used in tests.
   changeChanged(oldChange: ParsedChangeInfo | undefined) {
     this.allPatchSets = computeAllPatchSets(this.change);
@@ -2262,57 +1993,11 @@
       this.currentRobotCommentsPatchSet =
         this.change.revisions[this.change.current_revision]._number;
     }
-    if (!this.change || !this.patchRange || !this.allPatchSets) {
-      return;
-    }
-
-    // We get the parent first so we keep the original value for basePatchNum
-    // and not the updated value.
-    const parent = this.getBasePatchNum();
-
-    this.patchRange = {
-      ...this.patchRange,
-      basePatchNum: parent,
-      patchNum:
-        this.patchRange.patchNum || computeLatestPatchNum(this.allPatchSets),
-    };
-    this.updateTitle(this.change);
   }
 
   /**
-   * Gets base patch number, if it is a parent try and decide from
-   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
-   * Private but used in tests.
+   * This is the URL equivalent of changeModel.navigateToChangeResetReload().
    */
-  getBasePatchNum() {
-    if (
-      this.patchRange &&
-      this.patchRange.basePatchNum &&
-      this.patchRange.basePatchNum !== PARENT
-    ) {
-      return this.patchRange.basePatchNum;
-    }
-
-    const revisionInfo = this.getRevisionInfo();
-    if (!revisionInfo) return PARENT;
-
-    // TODO: It is a bit unclear why `1` is used here instead of
-    // `patchRange.patchNum`. Maybe that is a bug? Maybe if one patchset
-    // is a merge commit, then all patchsets are merge commits??
-    const isMerge = revisionInfo.isMergeCommit(1 as PatchSetNumber);
-    const preferFirst =
-      this.prefs &&
-      this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
-
-    // Verified via reportExecution that -1 is returned(1-5 times per day)
-    // changeChanged does set this.patchRange?.patchNum so it's still unclear
-    // how it is undefined.
-    if (isMerge && preferFirst && !this.patchRange?.patchNum) {
-      return -1 as BasePatchSetNum;
-    }
-    return PARENT;
-  }
-
   private computeChangeUrl(forceReload?: boolean) {
     if (!this.change) return undefined;
     return createChangeUrl({
@@ -2323,18 +2008,9 @@
 
   // Private but used in tests.
   computeReplyButtonLabel() {
-    if (this.diffDrafts === undefined) {
-      return 'Reply';
-    }
-
-    const draftCount = Object.keys(this.diffDrafts).reduce(
-      (count, file) => count + this.diffDrafts![file].length,
-      0
-    );
-
     let label = this.canStartReview() ? 'Start Review' : 'Reply';
-    if (draftCount > 0) {
-      label += ` (${draftCount})`;
+    if (this.draftCount > 0) {
+      label += ` (${this.draftCount})`;
     }
     return label;
   }
@@ -2397,13 +2073,13 @@
   // Private but used in tests.
   handleDiffAgainstBase() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
-    if (this.patchRange.basePatchNum === PARENT) {
+    assertIsDefined(this.patchNum, 'patchNum');
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
     this.getNavigation().setUrl(
-      createChangeUrl({change: this.change, patchNum: this.patchRange.patchNum})
+      createChangeUrl({change: this.change, patchNum: this.patchNum})
     );
   }
 
@@ -2411,16 +2087,16 @@
   handleDiffBaseAgainstLeft() {
     if (this.viewState?.childView !== ChangeChildView.OVERVIEW) return;
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this.patchRange.basePatchNum === PARENT) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
     this.getNavigation().setUrl(
       createChangeUrl({
         change: this.change,
-        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+        patchNum: this.basePatchNum as RevisionPatchSetNum,
       })
     );
   }
@@ -2428,9 +2104,9 @@
   // Private but used in tests.
   handleDiffAgainstLatest() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
@@ -2438,7 +2114,7 @@
       createChangeUrl({
         change: this.change,
         patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+        basePatchNum: this.basePatchNum,
       })
     );
   }
@@ -2446,9 +2122,9 @@
   // Private but used in tests.
   handleDiffRightAgainstLatest() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
@@ -2456,7 +2132,7 @@
       createChangeUrl({
         change: this.change,
         patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
+        basePatchNum: this.patchNum as BasePatchSetNum,
       })
     );
   }
@@ -2464,12 +2140,9 @@
   // Private but used in tests.
   handleDiffBaseAgainstLatest() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (
-      this.patchRange.patchNum === latestPatchNum &&
-      this.patchRange.basePatchNum === PARENT
-    ) {
+    if (this.patchNum === latestPatchNum && this.basePatchNum === PARENT) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
@@ -2545,14 +2218,14 @@
     });
   }
 
-  openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
+  openReplyDialog(focusTarget?: FocusTarget) {
     if (!this.change) return;
     this.replyModalOpened = true;
     assertIsDefined(this.replyModal);
     this.replyModal.showModal();
     whenVisible(this.replyModal, () => {
       assertIsDefined(this.replyDialog, 'replyDialog');
-      this.replyDialog.open(focusTarget, quote);
+      this.replyDialog.open(focusTarget);
     });
     fireDialogChange(this, {opened: true});
     this.changeViewAriaHidden = true;
@@ -2563,179 +2236,11 @@
     // TODO(wyatta) switch linkify sequence, see issue 5526.
     // This is a zero-with space. It is added to prevent the linkify library
     // from including R= or CC= as part of the email address.
+    // TODO: Is this comment referring to the ba-linkify library that we are
+    // not using anymore? If so, then remove this hack.
     return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
   }
 
-  /**
-   * Utility function to make the necessary modifications to a change in the
-   * case an edit exists.
-   * Private but used in tests.
-   */
-  processEdit(change: ParsedChangeInfo) {
-    const revisions = Object.values(change.revisions || {});
-    const editRev = findEdit(revisions);
-    const editParentRev = findEditParentRevision(revisions);
-    if (
-      !editRev &&
-      this.patchRange?.patchNum === EDIT &&
-      changeIsOpen(change)
-    ) {
-      fireAlert(this, 'Change edit not found. Please create a change edit.');
-      fireReload(this, true);
-      return;
-    }
-
-    if (
-      !editRev &&
-      (changeIsMerged(change) || changeIsAbandoned(change)) &&
-      this.getEditMode()
-    ) {
-      fireAlert(
-        this,
-        'Change edits cannot be created if change is merged or abandoned. Redirecting to non edit mode.'
-      );
-      fireReload(this, true);
-      return;
-    }
-
-    if (!editRev) return;
-    assertIsDefined(this.patchRange, 'patchRange');
-    assertIsDefined(editRev.commit.commit, 'editRev.commit.commit');
-    assertIsDefined(editParentRev, 'editParentRev');
-
-    const latestPsNum = computeLatestPatchNum(computeAllPatchSets(change));
-    // If the change was loaded without a specific patchset, then this normally
-    // means that the *latest* patchset should be loaded. But if there is an
-    // active edit, then automatically switch to that edit as the current
-    // patchset.
-    // TODO: This goes together with `change.current_revision` being set, which
-    // is under change-model control. `patchRange.patchNum` should eventually
-    // also be model managed, so we can reconcile these two code snippets into
-    // one location.
-    if (!this.viewModelPatchNum && latestPsNum === editParentRev._number) {
-      this.patchRange = {...this.patchRange, patchNum: EDIT};
-      // The file list is not reactive (yet) with regards to patch range
-      // changes, so we have to actively trigger it.
-      this.reloadPatchNumDependentResources();
-    }
-  }
-
-  computeRevertSubmitted(change?: ChangeInfo | ParsedChangeInfo) {
-    if (!change?.messages) return;
-    Promise.all(
-      getRevertCreatedChangeIds(change.messages).map(changeId =>
-        this.restApiService.getChange(changeId)
-      )
-    ).then(changes => {
-      // if a change is deleted then getChanges returns null for that changeId
-      changes = changes.filter(
-        change => change && change.status !== ChangeStatus.ABANDONED
-      );
-      if (!changes.length) return;
-      const submittedRevert = changes.find(
-        change => change?.status === ChangeStatus.MERGED
-      );
-      if (!this.changeStatuses) return;
-      // Protect against `computeRevertSubmitted()` being called twice.
-      // TODO: Convert this to be rxjs based, so computeRevertSubmitted() is not
-      // actively called, but instead we can subscribe to something.
-      if (this.changeStatuses.includes(ChangeStates.REVERT_SUBMITTED)) return;
-      if (this.changeStatuses.includes(ChangeStates.REVERT_CREATED)) return;
-      if (submittedRevert) {
-        this.revertedChange = submittedRevert;
-        this.changeStatuses = this.changeStatuses.concat([
-          ChangeStates.REVERT_SUBMITTED,
-        ]);
-      } else {
-        if (changes[0]) this.revertedChange = changes[0];
-        this.changeStatuses = this.changeStatuses.concat([
-          ChangeStates.REVERT_CREATED,
-        ]);
-      }
-    });
-  }
-
-  private async untilModelLoaded() {
-    // NOTE: Wait until this page is connected before determining whether the
-    // model is loaded.  This can happen when viewState changes when setting up
-    // this view. It's unclear whether this issue is related to Polymer
-    // specifically.
-    if (!this.isConnected) {
-      await until(this.connected$, connected => connected);
-    }
-    await until(
-      this.getChangeModel().changeLoadingStatus$,
-      status => status === LoadingStatus.LOADED
-    );
-  }
-
-  /**
-   * Process edits
-   * Check if a revert of this change has been submitted
-   * Calculate selected revision
-   */
-  // private but used in tests
-  async performPostChangeLoadTasks() {
-    assertIsDefined(this.changeNum, 'changeNum');
-
-    const prefCompletes = this.restApiService.getPreferences();
-    await this.untilModelLoaded();
-
-    this.prefs = await prefCompletes;
-
-    if (!this.change) return false;
-
-    this.processEdit(this.change);
-    // Issue 4190: Coalesce missing topics to null.
-    // TODO(TS): code needs second thought,
-    // it might be that nulls were assigned to trigger some bindings
-    if (!this.change.topic) {
-      this.change.topic = null as unknown as undefined;
-    }
-    if (!this.change.reviewer_updates) {
-      this.change.reviewer_updates = null as unknown as undefined;
-    }
-    const latestRevisionSha = this.getLatestRevisionSHA(this.change);
-    if (!latestRevisionSha)
-      throw new Error('Could not find latest Revision Sha');
-    const currentRevision = this.change.revisions[latestRevisionSha];
-    if (currentRevision.commit && currentRevision.commit.message) {
-      this.latestCommitMessage = this.prepareCommitMsgForLinkify(
-        currentRevision.commit.message
-      );
-    } else {
-      this.latestCommitMessage = null;
-    }
-
-    this.computeRevertSubmitted(this.change);
-    if (
-      !this.patchRange ||
-      !this.patchRange.patchNum ||
-      this.patchRange.patchNum === currentRevision._number
-    ) {
-      // CommitInfo.commit is optional, and may need patching.
-      if (currentRevision.commit && !currentRevision.commit.commit) {
-        currentRevision.commit.commit = latestRevisionSha as CommitId;
-      }
-      this.commitInfo = currentRevision.commit;
-      this.selectedRevision = currentRevision;
-      // TODO: Fetch and process files.
-    } else {
-      if (!this.change?.revisions || !this.patchRange) return false;
-      this.selectedRevision = Object.values(this.change.revisions).find(
-        revision => {
-          // edit patchset is a special one
-          const thePatchNum = this.patchRange!.patchNum;
-          if (thePatchNum === EDIT) {
-            return revision._number === thePatchNum;
-          }
-          return revision._number === Number(`${thePatchNum}`);
-        }
-      );
-    }
-    return true;
-  }
-
   private isParentCurrent() {
     const revisionActions = this.currentRevisionActions;
     if (revisionActions && revisionActions.rebase) {
@@ -2745,226 +2250,35 @@
     }
   }
 
-  // Private but used in tests.
-  getLatestCommitMessage() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    const lastpatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (lastpatchNum === undefined)
-      throw new Error('missing lastPatchNum property');
-    return this.restApiService
-      .getChangeCommitInfo(this.changeNum, lastpatchNum)
-      .then(commitInfo => {
-        if (!commitInfo) return;
-        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
-          commitInfo.message
-        );
-      });
+  private async reportChangeDisplayed() {
+    await waitUntil(() => !!this.metadata);
+    await untilRendered(this.metadata!);
+    await waitUntil(() => !!this.fileList);
+    await untilRendered(this.fileList!);
+    await waitUntil(() => !!this.messagesList);
+    await untilRendered(this.messagesList!);
+    // We are ending the timer after each change view update, because ending a
+    // timer that was not started is a no-op. :-)
+    if (this.change && this.isConnected && !this.isChangeObsolete()) {
+      this.reporting.changeDisplayed(roleDetails(this.change, this.account));
+    }
   }
 
-  // Private but used in tests.
-  getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
-    if (change.current_revision) return change.current_revision;
-    // current_revision may not be present in the case where the latest rev is
-    // a draft and the user doesn’t have permission to view that rev.
-    let latestRev = null;
-    let latestPatchNum = -1 as PatchSetNum;
-    for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
-      if (revInfo._number > latestPatchNum) {
-        latestRev = rev;
-        latestPatchNum = revInfo._number;
-      }
+  private async reportFullyLoaded() {
+    await waitUntil(() => !!this.metadata);
+    await untilRendered(this.metadata!);
+    await waitUntil(() => !!this.fileList);
+    await untilRendered(this.fileList!);
+    await waitUntil(() => !!this.messagesList);
+    await untilRendered(this.messagesList!);
+    await waitUntil(() => this.mergeable !== undefined);
+    await until(this.getCommentsModel().comments$, c => c !== undefined);
+    await until(this.getCommentsModel().drafts$, c => c !== undefined);
+    // We are ending the timer after each change view update, because ending a
+    // timer that was not started is a no-op. :-)
+    if (this.change && this.isConnected && !this.isChangeObsolete()) {
+      this.reporting.changeFullyLoaded();
     }
-    return latestRev;
-  }
-
-  // visible for testing
-  loadAndSetCommitInfo() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.patchRange?.patchNum, 'patchRange.patchNum');
-    return this.restApiService
-      .getChangeCommitInfo(this.changeNum, this.patchRange.patchNum)
-      .then(commitInfo => {
-        this.commitInfo = commitInfo;
-      });
-  }
-
-  /**
-   * Reload the change.
-   *
-   * @param isLocationChange Reloads the related changes
-   * when true and ends reporting events that started on location change.
-   * @param clearPatchset Reloads the change ignoring any patchset
-   * choice made.
-   * @return A promise that resolves when the core data has loaded.
-   * Some non-core data loading may still be in-flight when the core data
-   * promise resolves.
-   */
-  loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
-    if (this.isChangeObsolete()) return Promise.resolve();
-    if (clearPatchset && this.change) {
-      this.getNavigation().setUrl(
-        createChangeUrl({change: this.change, forceReload: true})
-      );
-      return Promise.resolve();
-    }
-    this.loading = true;
-    this.reporting.time(Timing.CHANGE_RELOAD);
-    this.reporting.time(Timing.CHANGE_DATA);
-
-    // Array to house all promises related to data requests.
-    const allDataPromises: Promise<unknown>[] = [];
-
-    // Resolves when the change detail and the edit patch set (if available)
-    // are loaded.
-    const detailCompletes = this.untilModelLoaded();
-    allDataPromises.push(detailCompletes);
-
-    // Resolves when the loading flag is set to false, meaning that some
-    // change content may start appearing.
-    const loadingFlagSet = detailCompletes.then(() => {
-      this.loading = false;
-      this.performPostChangeLoadTasks();
-    });
-
-    let coreDataPromise;
-
-    // If the patch number is specified
-    if (this.patchRange && this.patchRange.patchNum) {
-      // Because a specific patchset is specified, reload the resources that
-      // are keyed by patch number or patch range.
-      const patchResourcesLoaded = this.reloadPatchNumDependentResources();
-      allDataPromises.push(patchResourcesLoaded);
-
-      // Promise resolves when the change detail and patch dependent resources
-      // have loaded.
-      coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]);
-    } else {
-      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
-        // If the latest commit message is known, there is nothing to do.
-        if (this.latestCommitMessage) {
-          return Promise.resolve();
-        }
-        return this.getLatestCommitMessage();
-      });
-      allDataPromises.push(latestCommitMessageLoaded);
-
-      coreDataPromise = loadingFlagSet;
-    }
-    const mergeabilityLoaded = coreDataPromise.then(() =>
-      this.getMergeability()
-    );
-    allDataPromises.push(mergeabilityLoaded);
-
-    coreDataPromise.then(() => {
-      fire(this, 'change-details-loaded', {});
-      this.reporting.timeEnd(Timing.CHANGE_RELOAD);
-      if (isLocationChange) {
-        this.reporting.changeDisplayed(roleDetails(this.change, this.account));
-      }
-    });
-
-    if (isLocationChange) {
-      this.editingCommitMessage = false;
-    }
-    const relatedChangesLoaded = coreDataPromise.then(() => {
-      let relatedChangesPromise:
-        | Promise<RelatedChangesInfo | undefined>
-        | undefined;
-      const patchNum = computeLatestPatchNum(this.allPatchSets);
-      if (this.change && patchNum) {
-        relatedChangesPromise = this.restApiService
-          .getRelatedChanges(this.change._number, patchNum)
-          .then(response => {
-            if (this.change && response) {
-              this.hasParent = this.calculateHasParent(
-                this.change.change_id,
-                response.changes
-              );
-            }
-            return response;
-          });
-      }
-      return this.getRelatedChangesList()?.reload(relatedChangesPromise);
-    });
-    allDataPromises.push(relatedChangesLoaded);
-    allDataPromises.push(this.filesLoaded());
-
-    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();
-      }
-    });
-
-    return coreDataPromise;
-  }
-
-  private async filesLoaded() {
-    if (!this.isConnected) await until(this.connected$, connected => connected);
-    await until(this.getFilesModel().files$, f => f.length > 0);
-  }
-
-  /**
-   * Determines whether or not the given change has a parent change. If there
-   * is a relation chain, and the change id is not the last item of the
-   * relation chain, there is a parent.
-   *
-   * Private but used in tests.
-   */
-  calculateHasParent(
-    currentChangeId: ChangeId,
-    relatedChanges: RelatedChangeAndCommitInfo[]
-  ) {
-    return (
-      relatedChanges.length > 0 &&
-      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
-    );
-  }
-
-  /**
-   * Kicks off requests for resources that rely on the patch range
-   * (`this.patchRange`) being defined.
-   */
-  reloadPatchNumDependentResources() {
-    return this.loadAndSetCommitInfo();
-  }
-
-  // Private but used in tests
-  getMergeability(): Promise<void> {
-    if (!this.change) {
-      this.mergeable = null;
-      return Promise.resolve();
-    }
-    // If the change is closed, it is not mergeable. Note: already merged
-    // changes are obviously not mergeable, but the mergeability API will not
-    // answer for abandoned changes.
-    if (
-      this.change.status === ChangeStatus.MERGED ||
-      this.change.status === ChangeStatus.ABANDONED
-    ) {
-      this.mergeable = false;
-      return Promise.resolve();
-    }
-
-    if (!this.changeNum) {
-      return Promise.reject(new Error('missing required changeNum property'));
-    }
-
-    // If mergeable bit was already returned in detail REST endpoint, use it.
-    if (this.change.mergeable !== undefined) {
-      this.mergeable = this.change.mergeable;
-      return Promise.resolve();
-    }
-
-    this.mergeable = null;
-    return this.restApiService
-      .getMergeable(this.changeNum)
-      .then(mergableInfo => {
-        if (mergableInfo) {
-          this.mergeable = mergableInfo.mergeable;
-        }
-      });
   }
 
   /**
@@ -2980,13 +2294,11 @@
     );
   }
 
-  private computeCommitCollapsible() {
-    if (!this.latestCommitMessage) {
-      return false;
-    }
+  private computeCommitCollapsible(): boolean {
     return (
+      !!this.latestCommitMessage &&
       this.latestCommitMessage.split('\n').length >=
-      MIN_LINES_FOR_COMMIT_COLLAPSE
+        MIN_LINES_FOR_COMMIT_COLLAPSE
     );
   }
 
@@ -3047,7 +2359,7 @@
             dismissOnNavigation: true,
             showDismiss: true,
             action: 'Reload',
-            callback: () => fireReload(this, true),
+            callback: () => this.getChangeModel().navigateToChangeResetReload(),
           });
         });
     }, this.serverConfig.change.update_delay * 1000);
@@ -3071,7 +2383,7 @@
   // Private but used in tests.
   computeHeaderClass() {
     const classes = ['header'];
-    if (this.getEditMode()) {
+    if (this.editMode) {
       classes.push('editMode');
     }
     return classes.join(' ');
@@ -3086,7 +2398,7 @@
       );
     if (!controls) throw new Error('Missing edit controls');
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
 
     const path = e.detail.path;
     switch (e.detail.action) {
@@ -3094,12 +2406,12 @@
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        assertIsDefined(this.patchRange.patchNum, 'patchset number');
+        assertIsDefined(this.patchNum, 'patchset number');
         this.getNavigation().setUrl(
           createEditUrl({
             changeNum: this.change._number,
             repo: this.change.project,
-            patchNum: this.patchRange.patchNum,
+            patchNum: this.patchNum,
             editView: {path},
           })
         );
@@ -3113,21 +2425,6 @@
     }
   }
 
-  private patchNumChanged() {
-    if (!this.selectedRevision || !this.patchRange?.patchNum) {
-      return;
-    }
-    assertIsDefined(this.change, 'change');
-
-    if (this.patchRange.patchNum === this.selectedRevision._number) {
-      return;
-    }
-    if (!this.change.revisions) return;
-    this.selectedRevision = Object.values(this.change.revisions).find(
-      revision => revision._number === this.patchRange!.patchNum
-    );
-  }
-
   /**
    * If an edit exists already, load it. Otherwise, toggle edit mode via the
    * navigation API.
@@ -3157,11 +2454,11 @@
 
   private handleStopEditTap() {
     assertIsDefined(this.change, 'change');
-    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.patchNum, 'patchNum');
     this.getNavigation().setUrl(
       createChangeUrl({
         change: this.change,
-        patchNum: this.patchRange.patchNum,
+        patchNum: this.patchNum,
         forceReload: true,
       })
     );
@@ -3191,17 +2488,6 @@
     fire(this, 'hide-alert', {});
   }
 
-  private getRevisionInfo(): RevisionInfoClass | undefined {
-    if (this.change === undefined) return undefined;
-    return new RevisionInfoClass(this.change);
-  }
-
-  getRelatedChangesList() {
-    return this.shadowRoot!.querySelector<GrRelatedChangesList>(
-      '#relatedChanges'
-    );
-  }
-
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
     return this.getShortcutsService().createTitle(shortcutName, section);
   }
@@ -3216,7 +2502,6 @@
 declare global {
   interface HTMLElementEventMap {
     'toggle-star': CustomEvent<ChangeStarToggleStarDetail>;
-    'change-details-loaded': CustomEvent<{}>;
   }
   interface HTMLElementTagNameMap {
     'gr-change-view': GrChangeView;
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 4d97318..ae60449 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
@@ -10,9 +10,7 @@
 import {
   ChangeStatus,
   CommentSide,
-  DefaultBase,
   DiffViewMode,
-  MessageTag,
   createDefaultPreferences,
   Tab,
 } from '../../../constants/constants';
@@ -25,85 +23,62 @@
   queryAndAssert,
   stubFlags,
   stubRestApi,
-  waitEventLoop,
-  waitQueryAndAssert,
   waitUntil,
   waitUntilVisible,
 } from '../../../test/test-utils';
 import {
   createChangeViewState,
-  createApproval,
-  createChange,
   createChangeMessages,
-  createCommit,
-  createMergeable,
-  createPreferences,
   createRevision,
   createRevisions,
   createServerInfo,
   createUserConfig,
   TEST_NUMERIC_CHANGE_ID,
   TEST_PROJECT_NAME,
-  createEditRevision,
-  createAccountWithIdNameAndEmail,
   createChangeViewChange,
-  createRelatedChangeAndCommitInfo,
   createAccountDetailWithId,
   createParsedChange,
-  createDraft,
 } from '../../../test/test-data-generators';
 import {GrChangeView} from './gr-change-view';
 import {
   AccountId,
-  ApprovalInfo,
   BasePatchSetNum,
-  ChangeId,
-  ChangeInfo,
   CommitId,
   EDIT,
   NumericChangeId,
   PARENT,
-  RelatedChangeAndCommitInfo,
-  ReviewInputTag,
-  RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
   RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
-  DetailedLabelInfo,
   RepoName,
-  QuickLabelInfo,
-  PatchSetNumber,
   CommentThread,
-  ChangeStates,
+  SavingState,
 } from '../../../types/common';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {SinonFakeTimers} from 'sinon';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
-import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {
   ChangeModel,
   changeModelToken,
   LoadingStatus,
 } from '../../../models/change/change-model';
-import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
+import {FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {assertIsDefined} from '../../../utils/common-util';
-import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list';
 import {fixture, html, assert} from '@open-wc/testing';
-import {deepClone} from '../../../utils/deep-util';
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {commentsModelToken} from '../../../models/comments/comments-model';
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
@@ -154,7 +129,7 @@
           updated: '2018-02-13 22:48:48.018000000' as Timestamp,
           message: 'draft',
           unresolved: false,
-          __draft: true,
+          savingState: SavingState.OK,
           patch_set: 2 as RevisionPatchSetNum,
         },
       ],
@@ -257,7 +232,7 @@
           updated: '2018-02-15 22:48:48.018000000' as Timestamp,
           message: 'resolved draft',
           unresolved: false,
-          __draft: true,
+          savingState: SavingState.OK,
           patch_set: 2 as RevisionPatchSetNum,
         },
       ],
@@ -458,8 +433,7 @@
                     </gr-endpoint-decorator>
                   </div>
                   <div class="relatedChanges">
-                    <gr-related-changes-list id="relatedChanges">
-                    </gr-related-changes-list>
+                    <gr-related-changes-list></gr-related-changes-list>
                   </div>
                   <div class="emptySpace"></div>
                 </div>
@@ -545,10 +519,7 @@
 
   test('handleMessageAnchorTap', async () => {
     element.changeNum = 1 as NumericChangeId;
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
+    element.patchNum = 1 as RevisionPatchSetNum;
     element.change = createChangeViewChange();
     await element.updateComplete;
     const replaceStateStub = sinon.stub(history, 'replaceState');
@@ -564,10 +535,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      patchNum: 3 as RevisionPatchSetNum,
-      basePatchNum: 1 as BasePatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffAgainstBase();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3');
@@ -578,10 +547,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffAgainstLatest();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..10');
@@ -592,10 +559,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      patchNum: 3 as RevisionPatchSetNum,
-      basePatchNum: 1 as BasePatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffBaseAgainstLeft();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
@@ -606,10 +571,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffRightAgainstLatest();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..10');
@@ -620,10 +583,8 @@
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     element.handleDiffBaseAgainstLatest();
     assert.isTrue(setUrlStub.called);
     assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/10');
@@ -641,10 +602,8 @@
     const removeFromAttentionSetStub = stubRestApi(
       'removeFromAttentionSet'
     ).returns(Promise.resolve(new Response()));
-    element.patchRange = {
-      basePatchNum: 1 as BasePatchSetNum,
-      patchNum: 3 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as RevisionPatchSetNum;
     await element.updateComplete;
     assert.isNotOk(element.change.attention_set);
     element.handleToggleAttentionSet();
@@ -699,19 +658,6 @@
       assert.equal(element.activeTab, 'change-view-tab-header-url');
     });
 
-    test('param change should switch primary tab correctly', async () => {
-      assert.equal(element.activeTab, Tab.FILES);
-      // view is required
-      element.changeNum = undefined;
-      element.viewState = {
-        ...createChangeViewState(),
-        ...element.viewState,
-        tab: Tab.COMMENT_THREADS,
-      };
-      await element.updateComplete;
-      assert.equal(element.activeTab, Tab.COMMENT_THREADS);
-    });
-
     test('invalid param change should not switch primary tab', async () => {
       assert.equal(element.activeTab, Tab.FILES);
       // view is required
@@ -816,8 +762,6 @@
       stubRestApi('getChangeDetail').returns(
         Promise.resolve(createParsedChange())
       );
-      sinon.stub(element, 'performPostChangeLoadTasks');
-      sinon.stub(element, 'getMergeability');
       const change = {
         ...createChangeViewChange(),
         revisions: createRevisions(1),
@@ -947,10 +891,8 @@
   suite('thread list and change log tabs', () => {
     setup(() => {
       element.changeNum = TEST_NUMERIC_CHANGE_ID;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -970,12 +912,6 @@
           },
         },
       };
-      const relatedChanges = element.shadowRoot!.querySelector(
-        '#relatedChanges'
-      ) as GrRelatedChangesList;
-      sinon.stub(relatedChanges, 'reload');
-      sinon.stub(element, 'loadData').returns(Promise.resolve());
-      sinon.spy(element, 'viewStateChanged');
       element.viewState = createChangeViewState();
     });
   });
@@ -1155,183 +1091,6 @@
     );
   });
 
-  test('changeStatuses', async () => {
-    element.loading = false;
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev2: createRevision(2),
-        rev1: createRevision(1),
-        rev13: createRevision(13),
-        rev3: createRevision(3),
-      },
-      current_revision: 'rev3' as CommitId,
-      status: ChangeStatus.MERGED,
-      labels: {
-        test: {
-          all: [],
-          default_value: 0,
-          values: {},
-          approved: {},
-        },
-      },
-    };
-    element.mergeable = true;
-    await element.updateComplete;
-    const expectedStatuses = [ChangeStates.MERGED];
-    assert.deepEqual(element.changeStatuses, expectedStatuses);
-    const statusChips =
-      element.shadowRoot!.querySelectorAll('gr-change-status');
-    assert.equal(statusChips.length, 1);
-  });
-
-  suite('ChangeStatus revert', () => {
-    test('do not show any chip if no revert created', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-    });
-
-    test('do not show any chip if all reverts are abandoned', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      change.messages[0].message = 'Created a revert of this change as 12345';
-      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      change.messages[1].message = 'Created a revert of this change as 23456';
-      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-          status: ChangeStatus.ABANDONED,
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-          status: ChangeStatus.ABANDONED,
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-    });
-
-    test('show revert created if no revert is merged', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      change.messages[0].message = 'Created a revert of this change as 12345';
-      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      change.messages[1].message = 'Created a revert of this change as 23456';
-      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      // Wait for promises to settle.
-      await waitEventLoop();
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-      assert.isTrue(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-    });
-
-    test('show revert submitted if revert is merged', async () => {
-      const change = {
-        ...createParsedChange(),
-        messages: createChangeMessages(2),
-      };
-      change.messages[0].message = 'Created a revert of this change as 12345';
-      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-      const getChangeStub = stubRestApi('getChange');
-      getChangeStub.onFirstCall().returns(
-        Promise.resolve({
-          ...createChange(),
-          status: ChangeStatus.MERGED,
-        })
-      );
-      getChangeStub.onSecondCall().returns(
-        Promise.resolve({
-          ...createChange(),
-        })
-      );
-      element.change = change;
-      element.mergeable = true;
-      element.currentRevisionActions = {submit: {enabled: true}};
-      assert.isTrue(element.isSubmitEnabled());
-      await element.updateComplete;
-      element.computeRevertSubmitted(element.change);
-      // Wait for promises to settle.
-      await waitEventLoop();
-      await element.updateComplete;
-      assert.isFalse(
-        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
-      );
-      assert.isTrue(
-        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
-      );
-    });
-  });
-
   test('diff preferences open when open-diff-prefs is fired', async () => {
     await element.updateComplete;
     assertIsDefined(element.fileList);
@@ -1369,66 +1128,6 @@
     assert.isTrue(element.isSubmitEnabled());
   });
 
-  test('reload is called when an approved label is removed', async () => {
-    const vote: ApprovalInfo = {
-      ...createApproval(),
-      _account_id: 1 as AccountId,
-      name: 'bojack',
-      value: 1,
-    };
-    element.changeNum = TEST_NUMERIC_CHANGE_ID;
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    const change = {
-      ...createParsedChange(),
-      owner: createAccountWithIdNameAndEmail(),
-      revisions: {
-        rev2: createRevision(2),
-        rev1: createRevision(1),
-        rev13: createRevision(13),
-        rev3: createRevision(3),
-      },
-      current_revision: 'rev3' as CommitId,
-      status: ChangeStatus.NEW,
-      labels: {
-        test: {
-          all: [vote],
-          default_value: 0,
-          values: {},
-          approved: {},
-        },
-      },
-    };
-    element.change = change;
-    await element.updateComplete;
-    const reloadStub = sinon.stub(element, 'loadData');
-    const newChange = {...element.change};
-    (newChange.labels!.test! as DetailedLabelInfo).all = [];
-    element.change = deepClone(newChange);
-    await element.updateComplete;
-    assert.isFalse(reloadStub.called);
-
-    assert.isDefined(element.change);
-    const testLabels: DetailedLabelInfo & QuickLabelInfo =
-      newChange.labels!.test;
-    assertIsDefined(testLabels);
-    testLabels.all!.push(vote);
-    testLabels.all!.push(vote);
-    testLabels.approved = vote;
-    element.change = deepClone(newChange);
-    await element.updateComplete;
-    assert.isFalse(reloadStub.called);
-
-    assert.isDefined(element.change);
-    (newChange.labels!.test! as DetailedLabelInfo).all = [];
-    element.change = deepClone(newChange);
-    await element.updateComplete;
-    assert.isTrue(reloadStub.called);
-    assert.isTrue(reloadStub.calledOnce);
-  });
-
   test('reply button has updated count when there are drafts', () => {
     const getLabel = (canReview: boolean) => {
       element.change!.actions!.ready = {enabled: canReview};
@@ -1436,150 +1135,19 @@
     };
     element.change = createParsedChange();
     element.change.actions = {};
-    element.diffDrafts = undefined;
-    assert.equal(getLabel(false), 'Reply');
-    assert.equal(getLabel(true), 'Reply');
-
-    element.diffDrafts = {};
+    element.draftCount = 0;
     assert.equal(getLabel(false), 'Reply');
     assert.equal(getLabel(true), 'Start Review');
 
-    element.diffDrafts = {
-      'file1.txt': [createDraft()],
-      'file2.txt': [createDraft(), createDraft()],
-    };
+    element.draftCount = 0;
+    assert.equal(getLabel(false), 'Reply');
+    assert.equal(getLabel(true), 'Start Review');
+
+    element.draftCount = 3;
     assert.equal(getLabel(false), 'Reply (3)');
     assert.equal(getLabel(true), 'Start Review (3)');
   });
 
-  test('change num change', async () => {
-    const change = {
-      ...createChangeViewChange(),
-      labels: {},
-    } as ParsedChangeInfo;
-    element.changeNum = undefined;
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
-    element.change = change;
-    assertIsDefined(element.fileList);
-    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
-    element.fileList.numFilesShown = 150;
-    element.fileList.selectedIndex = 15;
-    await element.updateComplete;
-
-    element.changeNum = 2 as NumericChangeId;
-    element.viewState = {
-      ...createChangeViewState(),
-      changeNum: 2 as NumericChangeId,
-    };
-    await element.updateComplete;
-    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
-    assert.equal(element.fileList.selectedIndex, 0);
-  });
-
-  test('don’t reload entire page when patchRange changes', async () => {
-    const reloadStub = sinon
-      .stub(element, 'loadData')
-      .callsFake(() => Promise.resolve());
-    const reloadPatchDependentStub = sinon
-      .stub(element, 'reloadPatchNumDependentResources')
-      .callsFake(() => Promise.resolve());
-    assertIsDefined(element.fileList);
-    await element.fileList.updateComplete;
-    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    const value: ChangeViewState = {
-      ...createChangeViewState(),
-      view: GerritView.CHANGE,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    element.changeNum = undefined;
-    element.viewState = value;
-    await element.updateComplete;
-    assert.isTrue(reloadStub.calledOnce);
-
-    element.initialLoadComplete = true;
-    element.fileList.selectedIndex = 15;
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev2: createRevision(2),
-      },
-    };
-
-    value.basePatchNum = 1 as BasePatchSetNum;
-    value.patchNum = 2 as RevisionPatchSetNum;
-    element.viewState = {...value};
-    await element.updateComplete;
-    await waitEventLoop();
-    assert.equal(element.fileList.selectedIndex, 0);
-    assert.isFalse(reloadStub.calledTwice);
-    assert.isTrue(reloadPatchDependentStub.calledOnce);
-    assert.isTrue(collapseStub.calledTwice);
-  });
-
-  test('do not reload entire page when patchRange doesnt change', async () => {
-    assertIsDefined(element.fileList);
-    const reloadStub = sinon
-      .stub(element, 'loadData')
-      .callsFake(() => Promise.resolve());
-    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    const value: ChangeViewState = createChangeViewState();
-    element.viewState = value;
-    // change already loaded
-    assert.isOk(element.changeNum);
-    await element.updateComplete;
-    assert.isFalse(reloadStub.calledOnce);
-    element.initialLoadComplete = true;
-    element.viewState = {...value};
-    await element.updateComplete;
-    assert.isFalse(reloadStub.calledTwice);
-    assert.isFalse(collapseStub.calledTwice);
-  });
-
-  test('forceReload updates the change', async () => {
-    assertIsDefined(element.fileList);
-    const getChangeStub = stubRestApi('getChangeDetail').returns(
-      Promise.resolve(createParsedChange())
-    );
-    const loadDataStub = sinon
-      .stub(element, 'loadData')
-      .callsFake(() => Promise.resolve());
-    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    element.viewState = {...createChangeViewState(), forceReload: true};
-    await element.updateComplete;
-    assert.isTrue(getChangeStub.called);
-    assert.isTrue(loadDataStub.called);
-    assert.isTrue(collapseStub.called);
-    // patchNum is set by changeChanged, so this verifies that change was set.
-    assert.isOk(element.patchRange?.patchNum);
-  });
-
-  test('related changes are updated when loadData is called', async () => {
-    await element.updateComplete;
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    const reloadStub = sinon.stub(relatedChanges, 'reload');
-    stubRestApi('getMergeable').returns(
-      Promise.resolve({...createMergeable(), mergeable: true})
-    );
-
-    element.viewState = createChangeViewState();
-    changeModel.setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-      },
-    });
-
-    await element.loadData(true);
-    assert.isFalse(setUrlStub.called);
-    assert.isTrue(reloadStub.called);
-  });
-
   test('computeCopyTextForTitle', () => {
     element.change = {
       ...createChangeViewChange(),
@@ -1597,26 +1165,6 @@
     );
   });
 
-  test('get latest revision', () => {
-    let change: ChangeInfo = {
-      ...createChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev3: createRevision(3),
-      },
-      current_revision: 'rev3' as CommitId,
-    };
-    assert.equal(element.getLatestRevisionSHA(change), 'rev3');
-    change = {
-      ...createChange(),
-      revisions: {
-        rev1: createRevision(1),
-      },
-      current_revision: undefined,
-    };
-    assert.equal(element.getLatestRevisionSHA(change), 'rev1');
-  });
-
   test('show commit message edit button', () => {
     const change = createParsedChange();
     const mergedChanged: ParsedChangeInfo = {
@@ -1639,6 +1187,7 @@
   });
 
   test('handleCommitMessageSave trims trailing whitespace', async () => {
+    element.changeNum = TEST_NUMERIC_CHANGE_ID;
     element.change = createChangeViewChange();
     // Response code is 500, because we want to avoid window reloading
     const putStub = stubRestApi('putChangeCommitMessage').returns(
@@ -1659,85 +1208,6 @@
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
 
-  test('topic is coalesced to null', async () => {
-    sinon.stub(element, 'changeChanged');
-    changeModel.setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        labels: {},
-        current_revision: 'foo' as CommitId,
-        revisions: {foo: createRevision()},
-      },
-    });
-
-    await element.performPostChangeLoadTasks();
-    assert.isNull(element.change!.topic);
-  });
-
-  test('commit sha is populated from getChangeDetail', async () => {
-    changeModel.setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        labels: {},
-        current_revision: 'foo' as CommitId,
-        revisions: {foo: createRevision()},
-      },
-    });
-
-    await element.performPostChangeLoadTasks();
-    assert.equal('foo', element.commitInfo!.commit);
-  });
-
-  test('getBasePatchNum', async () => {
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
-      },
-    };
-    element.patchRange = {
-      basePatchNum: PARENT,
-    };
-    await element.updateComplete;
-    assert.equal(element.getBasePatchNum(), PARENT);
-
-    element.prefs = {
-      ...createPreferences(),
-      default_base_for_merges: DefaultBase.FIRST_PARENT,
-    };
-
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': {
-          ...createRevision(1),
-          commit: {
-            ...createCommit(),
-            parents: [
-              {
-                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
-                subject: 'test',
-              },
-              {
-                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
-                subject: 'test3',
-              },
-            ],
-          },
-        },
-      },
-    };
-    await element.updateComplete;
-    assert.equal(element.getBasePatchNum(), -1 as BasePatchSetNum);
-
-    element.patchRange.basePatchNum = PARENT;
-    element.patchRange.patchNum = 1 as RevisionPatchSetNum;
-    await element.updateComplete;
-    assert.equal(element.getBasePatchNum(), PARENT);
-  });
-
   test('openReplyDialog called with `ANY` when coming from tap event', async () => {
     await element.updateComplete;
     assertIsDefined(element.replyBtn);
@@ -1750,24 +1220,6 @@
     assert.equal(openStub.callCount, 1);
   });
 
-  test(
-    'openReplyDialog called with `BODY` when coming from message reply' +
-      'event',
-    async () => {
-      await element.updateComplete;
-      const openStub = sinon.stub(element, 'openReplyDialog');
-      element.messagesList!.dispatchEvent(
-        new CustomEvent('reply', {
-          detail: {message: {message: 'text'}},
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(openStub.calledOnce);
-      assert.equal(openStub.lastCall.args[0], FocusTarget.BODY);
-    }
-  );
-
   test('reply dialog focus can be controlled', () => {
     const openStub = sinon.stub(element, 'openReplyDialog');
 
@@ -1809,10 +1261,8 @@
       .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
 
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
+    element.basePatchNum = PARENT;
+    element.patchNum = 2 as RevisionPatchSetNum;
     element.change = {
       ...createChangeViewChange(),
       revisions: {
@@ -1866,122 +1316,33 @@
       await element.updateComplete;
       assert.isTrue(openReplyDialogStub.calledOnce);
     });
-
-    test('reply from comment adds quote text', async () => {
-      const change = {
-        ...createChangeViewChange(),
-        revisions: createRevisions(1),
-        messages: createChangeMessages(1),
-      };
-      changeModel.setState({
-        loadingStatus: LoadingStatus.LOADED,
-        change,
-      });
-      const e = new CustomEvent('', {
-        detail: {message: {message: 'quote text'}},
-      });
-      element.handleMessageReply(e);
-      const dialog = await waitQueryAndAssert<GrReplyDialog>(
-        element,
-        '#replyDialog'
-      );
-      const openSpy = sinon.spy(dialog, 'open');
-      await element.updateComplete;
-      await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
-      assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
-    });
   });
 
-  test('header class computation', () => {
+  test('header class computation', async () => {
     assert.equal(element.computeHeaderClass(), 'header');
-    assertIsDefined(element.viewState);
-    element.viewState.edit = true;
+    element.editMode = true;
     assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
   test('maybeScrollToMessage', async () => {
+    element.change = {
+      ...createChangeViewChange(),
+      messages: createChangeMessages(1),
+    };
     await element.updateComplete;
     const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
 
-    element.maybeScrollToMessage('');
+    await element.maybeScrollToMessage('');
     assert.isFalse(scrollStub.called);
-    element.maybeScrollToMessage('message');
+    await element.maybeScrollToMessage('message');
     assert.isFalse(scrollStub.called);
-    element.maybeScrollToMessage('#message-TEST');
+    await element.maybeScrollToMessage('#message-TEST');
     assert.isTrue(scrollStub.called);
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
-  test('computeEditMode', async () => {
-    const callCompute = async (viewState: ChangeViewState) => {
-      element.viewState = viewState;
-      await element.updateComplete;
-      return element.getEditMode();
-    };
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        edit: true,
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isFalse(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: EDIT,
-      })
-    );
-  });
-
-  test('processEdit', () => {
-    element.patchRange = {};
-    const change: ParsedChangeInfo = {
-      ...createChangeViewChange(),
-      current_revision: 'foo' as CommitId,
-      revisions: {
-        foo: {...createRevision()},
-      },
-    };
-
-    // With no edit, nothing happens.
-    element.processEdit(change);
-    assert.equal(element.patchRange.patchNum, undefined);
-
-    change.revisions['bar'] = {
-      _number: EDIT,
-      basePatchNum: 1 as BasePatchSetNum,
-      commit: {
-        ...createCommit(),
-        commit: 'bar' as CommitId,
-      },
-      fetch: {},
-    };
-
-    // When edit is set, but not patchNum, then switch to edit ps.
-    element.processEdit(change);
-    assert.equal(element.patchRange.patchNum, EDIT);
-
-    // When edit is set, but patchNum as well, then keep patchNum.
-    element.patchRange.patchNum = 5 as RevisionPatchSetNum;
-    element.viewModelPatchNum = 5 as RevisionPatchSetNum;
-    element.processEdit(change);
-    assert.equal(element.patchRange.patchNum, 5 as RevisionPatchSetNum);
-  });
-
   test('file-action-tap handling', async () => {
-    element.patchRange = {
-      basePatchNum: PARENT,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
+    element.patchNum = 1 as RevisionPatchSetNum;
     element.change = {
       ...createChangeViewChange(),
     };
@@ -2052,109 +1413,6 @@
     assert.isTrue(setUrlStub.called);
   });
 
-  test('selectedRevision updates when patchNum is changed', async () => {
-    const revision1: RevisionInfo = createRevision(1);
-    const revision2: RevisionInfo = createRevision(2);
-    changeModel.setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        revisions: {
-          aaa: revision1,
-          bbb: revision2,
-        },
-        labels: {},
-        actions: {},
-        current_revision: 'bbb' as CommitId,
-      },
-    });
-    userModel.setPreferences(createPreferences());
-
-    element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
-    await element.performPostChangeLoadTasks();
-    assert.strictEqual(element.selectedRevision, revision2);
-
-    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
-    await element.updateComplete;
-    assert.strictEqual(element.selectedRevision, revision1);
-  });
-
-  test('selectedRevision is assigned when patchNum is edit', async () => {
-    const revision1 = createRevision(1);
-    const revision2 = createRevision(2);
-    const revision3 = createEditRevision();
-    changeModel.setState({
-      loadingStatus: LoadingStatus.LOADED,
-      change: {
-        ...createChangeViewChange(),
-        revisions: {
-          aaa: revision1,
-          bbb: revision2,
-          ccc: revision3,
-        },
-        labels: {},
-        actions: {},
-        current_revision: 'ccc' as CommitId,
-      },
-    });
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
-
-    element.patchRange = {patchNum: EDIT};
-    await element.performPostChangeLoadTasks();
-    assert.strictEqual(element.selectedRevision, revision3);
-  });
-
-  test('sendShowChangeEvent', () => {
-    const change = {...createChangeViewChange(), labels: {}};
-    element.change = {...change};
-    element.patchRange = {patchNum: 4 as RevisionPatchSetNum};
-    element.mergeable = true;
-    const showStub = sinon.stub(
-      testResolver(pluginLoaderToken).jsApiService,
-      'handleShowChange'
-    );
-    element.sendShowChangeEvent();
-    assert.isTrue(showStub.calledOnce);
-    assert.deepEqual(showStub.lastCall.args[0], {
-      change,
-      patchNum: 4 as PatchSetNumber,
-      info: {mergeable: true},
-    });
-  });
-
-  test('patch range changed', () => {
-    element.patchRange = undefined;
-    element.change = createChangeViewChange();
-    element.change.revisions = createRevisions(4);
-    element.change.current_revision = '1' as CommitId;
-    element.change = {...element.change};
-
-    const viewState = createChangeViewState();
-
-    assert.isFalse(element.hasPatchRangeChanged(viewState));
-    assert.isFalse(element.hasPatchNumChanged(viewState));
-
-    viewState.basePatchNum = PARENT;
-    // undefined means navigate to latest patchset
-    viewState.patchNum = undefined;
-
-    element.patchRange = {
-      patchNum: 2 as RevisionPatchSetNum,
-      basePatchNum: PARENT,
-    };
-
-    assert.isTrue(element.hasPatchRangeChanged(viewState));
-    assert.isTrue(element.hasPatchNumChanged(viewState));
-
-    element.patchRange = {
-      patchNum: 4 as RevisionPatchSetNum,
-      basePatchNum: PARENT,
-    };
-
-    assert.isFalse(element.hasPatchRangeChanged(viewState));
-    assert.isFalse(element.hasPatchNumChanged(viewState));
-  });
-
   suite('handleEditTap', () => {
     let fireEdit: () => void;
 
@@ -2203,7 +1461,7 @@
       const newChange = {...element.change};
       newChange.revisions.rev2 = createRevision(2);
       element.change = newChange;
-      element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+      element.patchNum = 2 as RevisionPatchSetNum;
       await element.updateComplete;
 
       fireEdit();
@@ -2224,7 +1482,7 @@
     assertIsDefined(element.actions);
     sinon.stub(element.metadata, 'computeLabelNames');
 
-    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+    element.patchNum = 1 as RevisionPatchSetNum;
     element.actions.dispatchEvent(
       new CustomEvent('stop-edit-tap', {bubbles: false})
     );
@@ -2239,7 +1497,7 @@
   suite('plugin endpoints', () => {
     test('endpoint params', async () => {
       element.change = {...createChangeViewChange(), labels: {}};
-      element.selectedRevision = createRevision();
+      element.revision = createRevision();
       const promise = mockPromise();
       window.Gerrit.install(
         promise.resolve,
@@ -2253,43 +1511,7 @@
         .getLastAttached();
       assert.strictEqual((hookEl as any).plugin, plugin);
       assert.strictEqual((hookEl as any).change, element.change);
-      assert.strictEqual((hookEl as any).revision, element.selectedRevision);
-    });
-  });
-
-  suite('getMergeability', () => {
-    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
-    setup(() => {
-      element.change = {...createChangeViewChange(), labels: {}};
-      getMergeableStub = stubRestApi('getMergeable').returns(
-        Promise.resolve({...createMergeable(), mergeable: true})
-      );
-    });
-
-    test('merged change', () => {
-      element.mergeable = null;
-      element.change!.status = ChangeStatus.MERGED;
-      return element.getMergeability().then(() => {
-        assert.isFalse(element.mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('abandoned change', () => {
-      element.mergeable = null;
-      element.change!.status = ChangeStatus.ABANDONED;
-      return element.getMergeability().then(() => {
-        assert.isFalse(element.mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('open change', () => {
-      element.mergeable = null;
-      return element.getMergeability().then(() => {
-        assert.isTrue(element.mergeable);
-        assert.isTrue(getMergeableStub.called);
-      });
+      assert.strictEqual((hookEl as any).revision, element.revision);
     });
   });
 
@@ -2311,18 +1533,8 @@
 
   suite('gr-reporting tests', () => {
     setup(() => {
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      sinon
-        .stub(element, 'performPostChangeLoadTasks')
-        .returns(Promise.resolve(false));
-      sinon.stub(element, 'getMergeability').returns(Promise.resolve());
-      sinon.stub(element, 'getLatestCommitMessage').returns(Promise.resolve());
-      sinon
-        .stub(element, 'reloadPatchNumDependentResources')
-        .returns(Promise.resolve());
+      element.basePatchNum = PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
     });
 
     test("don't report changeDisplayed on reply", async () => {
@@ -2340,7 +1552,8 @@
       assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on viewStateChanged', async () => {
+    test('report changeDisplayed and changeFullyLoaded', async () => {
+      const commentsModel = testResolver(commentsModelToken);
       stubRestApi('getChangeOrEditFiles').resolves({
         'a-file.js': {},
       });
@@ -2368,32 +1581,23 @@
           revisions: {foo: createRevision()},
         },
       });
-      await element.updateComplete;
-      await waitEventLoop();
+
+      await waitUntil(() => changeDisplayStub.called);
       assert.isTrue(changeDisplayStub.called);
+      assert.isFalse(changeFullyLoadedStub.called);
+
+      element.mergeable = true;
+      commentsModel.setState({
+        comments: {},
+        drafts: {},
+        discardedDrafts: [],
+      });
+
+      await waitUntil(() => changeFullyLoadedStub.called);
       assert.isTrue(changeFullyLoadedStub.called);
     });
   });
 
-  test('calculateHasParent', () => {
-    const changeId = '123' as ChangeId;
-    const relatedChanges: RelatedChangeAndCommitInfo[] = [];
-
-    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
-
-    relatedChanges.push({
-      ...createRelatedChangeAndCommitInfo(),
-      change_id: '123' as ChangeId,
-    });
-    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
-
-    relatedChanges.push({
-      ...createRelatedChangeAndCommitInfo(),
-      change_id: '234' as ChangeId,
-    });
-    assert.equal(element.calculateHasParent(changeId, relatedChanges), true);
-  });
-
   test('renders sha in copy links', async () => {
     stubFlags('isEnabled').returns(true);
     const sha = '123' as CommitId;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 4d2602e..891209e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -39,6 +39,7 @@
 import {resolve} from '../../../models/dependency';
 import {createSearchUrl} from '../../../models/views/search';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {uuid} from '../../../utils/common-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -532,8 +533,7 @@
   }
 
   private generateRandomCherryPickTopic(change: ChangeInfo) {
-    const randomString = Math.random().toString(36).substr(2, 10);
-    const message = `cherrypick-${change.topic}-${randomString}`;
+    const message = `cherrypick-${change.topic}-${uuid()}`;
     return message;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 166d895..6ad416e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -28,8 +28,9 @@
 import {fireNoBubbleNoCompose} from '../../../utils/event-util';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
-import {subscribe} from '../../lit/subscription-controller';
 import {userModelToken} from '../../../models/user/user-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 export interface RebaseChange {
   name: string;
@@ -63,12 +64,6 @@
   @property({type: String})
   branch?: BranchName;
 
-  @property({type: Number})
-  changeNumber?: NumericChangeId;
-
-  @property({type: Boolean})
-  hasParent?: boolean;
-
   @property({type: Boolean})
   rebaseOnCurrent?: boolean;
 
@@ -76,6 +71,12 @@
   disableActions = false;
 
   @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  hasParent?: boolean;
+
+  @state()
   text = '';
 
   @state()
@@ -91,16 +92,16 @@
   allowConflicts = false;
 
   @query('#rebaseOnParentInput')
-  private rebaseOnParentInput!: HTMLInputElement;
+  private rebaseOnParentInput?: HTMLInputElement;
 
   @query('#rebaseOnTipInput')
-  private rebaseOnTipInput!: HTMLInputElement;
+  private rebaseOnTipInput?: HTMLInputElement;
 
   @query('#rebaseOnOtherInput')
-  rebaseOnOtherInput!: HTMLInputElement;
+  rebaseOnOtherInput?: HTMLInputElement;
 
   @query('#rebaseAllowConflicts')
-  private rebaseAllowConflicts!: HTMLInputElement;
+  private rebaseAllowConflicts?: HTMLInputElement;
 
   @query('#rebaseChain')
   private rebaseChain?: HTMLInputElement;
@@ -120,6 +121,11 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
     this.query = input => this.getChangeSuggestions(input);
@@ -133,6 +139,16 @@
       () => this.getChangeModel().latestUploader$,
       x => (this.uploader = x)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().hasParent$,
+      x => (this.hasParent = x)
+    );
   }
 
   override willUpdate(changedProperties: PropertyValues): void {
@@ -199,6 +215,9 @@
               Rebase on parent change
             </label>
           </div>
+          <div class="message" ?hidden=${this.hasParent !== undefined}>
+            Still loading parent information ...
+          </div>
           <div
             id="parentUpToDateMsg"
             class="message"
@@ -361,8 +380,7 @@
   ): AutocompleteSuggestion[] {
     return changes
       .filter(
-        change =>
-          change.name.includes(input) && change.value !== this.changeNumber
+        change => change.name.includes(input) && change.value !== this.changeNum
       )
       .map(
         change =>
@@ -393,10 +411,10 @@
    * should be rebased on top of its current parent.
    */
   getSelectedBase() {
-    if (this.rebaseOnParentInput.checked) {
+    if (this.rebaseOnParentInput?.checked) {
       return null;
     }
-    if (this.rebaseOnTipInput.checked) {
+    if (this.rebaseOnTipInput?.checked) {
       return '';
     }
     if (!this.text) {
@@ -412,7 +430,7 @@
     e.stopPropagation();
     const detail: ConfirmRebaseEventDetail = {
       base: this.getSelectedBase(),
-      allowConflicts: this.rebaseAllowConflicts.checked,
+      allowConflicts: !!this.rebaseAllowConflicts?.checked,
       rebaseChain: !!this.rebaseChain?.checked,
       onBehalfOfUploader: this.rebaseOnBehalfOfUploader(),
     };
@@ -437,7 +455,7 @@
   }
 
   private handleEnterChangeNumberClick() {
-    this.rebaseOnOtherInput.checked = true;
+    if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
   }
 
   /**
@@ -451,11 +469,11 @@
     }
 
     if (this.displayParentOption()) {
-      this.rebaseOnParentInput.checked = true;
+      if (this.rebaseOnParentInput) this.rebaseOnParentInput.checked = true;
     } else if (this.displayTipOption()) {
-      this.rebaseOnTipInput.checked = true;
+      if (this.rebaseOnTipInput) this.rebaseOnTipInput.checked = true;
     } else {
-      this.rebaseOnOtherInput.checked = true;
+      if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 8d907c9..24f8a34 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -44,6 +44,7 @@
 
   test('render', async () => {
     element.branch = 'test' as BranchName;
+    element.hasParent = false;
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
@@ -60,6 +61,9 @@
               Rebase on parent change
             </label>
           </div>
+          <div class="message" hidden="">
+            Still loading parent information ...
+          </div>
           <div class="message" hidden="" id="parentUpToDateMsg">
             This change is up to date with its parent.
           </div>
@@ -239,7 +243,7 @@
     element.hasParent = false;
     await element.updateComplete;
 
-    assert.isTrue(element.rebaseOnOtherInput.checked);
+    assert.isTrue(element.rebaseOnOtherInput?.checked);
     assert.isTrue(
       queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
     );
@@ -360,7 +364,7 @@
       assert.equal(element.filterChanges('awesome', recentChanges).length, 3);
       assert.equal(element.filterChanges('third', recentChanges).length, 1);
 
-      element.changeNumber = 123 as NumericChangeId;
+      element.changeNum = 123 as NumericChangeId;
       await element.updateComplete;
 
       assert.equal(element.filterChanges('123', recentChanges).length, 0);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index fedc377..e421c9c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -15,6 +15,7 @@
 import {resolve} from '../../../models/dependency';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {createSearchUrl} from '../../../models/views/search';
+import {ParsedChangeInfo} from '../../../types/types';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
@@ -170,15 +171,23 @@
     return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  modifyRevertMsg(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    message: string
+  ) {
     return this.getPluginLoader().jsApiService.modifyRevertMsg(
-      change,
+      change as ChangeInfo,
       message,
       commitMessage
     );
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+  populate(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    changesCount: number
+  ) {
     this.changesCount = changesCount;
     // The option to revert a single change is always available
     this.populateRevertSingleChangeMessage(
@@ -190,7 +199,7 @@
   }
 
   populateRevertSingleChangeMessage(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
   ) {
@@ -215,18 +224,21 @@
   }
 
   private modifyRevertSubmissionMsg(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     msg: string,
     commitMessage: string
   ) {
     return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
-      change,
+      change as ChangeInfo,
       msg,
       commitMessage
     );
   }
 
-  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
+  populateRevertSubmissionMessage(
+    change: ParsedChangeInfo,
+    commitMessage: string
+  ) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 904285f..8d71e15 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -5,7 +5,7 @@
  */
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
-import {createChange} from '../../../test/test-data-generators';
+import {createParsedChange} from '../../../test/test-data-generators';
 import {ChangeSubmissionId, CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
@@ -48,7 +48,7 @@
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'not a commitHash in sight',
       undefined
     );
@@ -58,7 +58,7 @@
   test('single line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -72,7 +72,7 @@
   test('multi line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -86,7 +86,7 @@
   test('issue above change id', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -100,7 +100,7 @@
   test('revert a revert', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -115,7 +115,7 @@
     element.changesCount = 3;
     element.populateRevertSubmissionMessage(
       {
-        ...createChange(),
+        ...createParsedChange(),
         submission_id: '5545' as ChangeSubmissionId,
         current_revision: 'abcd123' as CommitId,
       },
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index cdf618a..44e237b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -99,7 +99,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 11dc890..e1808bf 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -5,7 +5,7 @@
  */
 import '../../shared/gr-download-commands/gr-download-commands';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
+import {DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
@@ -13,13 +13,15 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state, query} from 'lit/decorators.js';
+import {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-download-dialog')
 export class GrDownloadDialog extends LitElement {
@@ -35,27 +37,37 @@
 
   @query('#closeButton') protected closeButton?: GrButton;
 
-  @property({type: Object})
-  change: ChangeInfo | undefined;
+  @state() change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  config?: DownloadInfo;
+  @state() config?: DownloadInfo;
 
   @state() patchNum?: PatchSetNum;
 
-  @state() private selectedScheme?: string;
+  @state() selectedScheme?: string;
 
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       x => (this.patchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().download$,
+      x => (this.config = x)
+    );
     for (const key of ['1', '2', '3', '4', '5']) {
       this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index e5f40a6..73d7618 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -8,6 +8,7 @@
   createChange,
   createCommit,
   createDownloadInfo,
+  createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
 import {
@@ -160,7 +161,7 @@
   suite('gr-download-dialog tests with no fetch options', () => {
     setup(async () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         revisions: {
           r1: {
             ...createRevision(),
@@ -204,7 +205,7 @@
 
     test('computed fields', () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         project: 'test/project' as RepoName,
         _number: 123 as NumericChangeId,
       };
@@ -233,7 +234,7 @@
     element.patchNum = 1 as PatchSetNum;
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
@@ -241,7 +242,7 @@
     assert.isTrue(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
@@ -255,7 +256,7 @@
     assert.isFalse(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
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 c206d57..9665b03 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
@@ -86,7 +86,6 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {FileMode, fileModeToString} from '../../../utils/file-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -309,8 +308,6 @@
 
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly flagsService = getAppContext().flagsService;
-
   shortcutsController = new ShortcutController(this);
 
   private readonly getNavigation = resolve(this, navigationToken);
@@ -826,6 +823,13 @@
 
   override willUpdate(changedProperties: PropertyValues): void {
     if (
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum')
+    ) {
+      this.resetFileState();
+      this.collapseAllDiffs();
+    }
+    if (
       changedProperties.has('diffPrefs') ||
       changedProperties.has('diffViewMode')
     ) {
@@ -1279,16 +1283,7 @@
   private renderFileComments(file: NormalizedFileInfo) {
     return html` <div role="gridcell">
       <div class="comments desktop">
-        ${when(
-          this.flagsService.isEnabled(
-            KnownExperimentId.COMMENTS_CHIPS_IN_FILE_LIST
-          ),
-          () => html`<span>${this.renderCommentsChips(file)}</span>`,
-          () => html`<span class="drafts"
-              >${this.computeDraftsString(file)}</span
-            >
-            <span>${this.computeCommentsString(file)}</span>`
-        )}
+        <span>${this.renderCommentsChips(file)}</span>
         <span class="noCommentsScreenReaderText">
           <!-- Screen readers read the following content only if 2 other
           spans in the parent div is empty. The content is not visible on
@@ -1817,37 +1812,6 @@
   }
 
   /**
-   * Computes a string with the number of comments and unresolved comments.
-   */
-  computeCommentsString(file?: NormalizedFileInfo) {
-    if (
-      this.changeComments === undefined ||
-      this.patchRange === undefined ||
-      file?.__path === undefined
-    ) {
-      return '';
-    }
-    return this.changeComments.computeCommentsString(
-      this.patchRange,
-      file.__path,
-      file
-    );
-  }
-
-  /**
-   * Computes a string with the number of drafts.
-   */
-  computeDraftsString(file?: NormalizedFileInfo) {
-    if (this.changeComments === undefined) return '';
-    const draftCount = this.changeComments.computeDraftCountForFile(
-      this.patchRange,
-      file
-    );
-    if (draftCount === 0) return '';
-    return pluralize(Number(draftCount), 'draft');
-  }
-
-  /**
    * Computes a shortened string with the number of drafts.
    * Private but used in tests.
    */
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 8fb5622..939ad06 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -192,7 +192,11 @@
           </span>
           <div role="gridcell">
             <div class="comments desktop">
-              <span class="drafts"> </span> <span> </span>
+              <span
+                ><gr-comments-summary
+                  emptywhennocomments=""
+                ></gr-comments-summary
+              ></span>
               <span class="noCommentsScreenReaderText"> No comments </span>
             </div>
             <div class="comments mobile">
@@ -719,28 +723,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'unresolved.file',
-          size: 0,
-          size_delta: 0,
-        }),
-        '1 draft'
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'unresolved.file',
-          size: 0,
-          size_delta: 0,
-        }),
-        '1 draft'
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
@@ -785,28 +767,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
@@ -851,28 +811,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'file_added_in_rev2.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'file_added_in_rev2.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
@@ -917,28 +855,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: '/COMMIT_MSG',
-          size: 0,
-          size_delta: 0,
-        }),
-        '2 drafts'
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: '/COMMIT_MSG',
-          size: 0,
-          size_delta: 0,
-        }),
-        '2 drafts'
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index 33dfe82..32da400 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -10,9 +10,12 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireNoBubble} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 interface DisplayGroup {
   title: string;
@@ -27,19 +30,18 @@
    * @event close
    */
 
-  @property({type: Object})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  // private but used in test
   @state() includedIn?: IncludedInInfo;
 
   @state() private loaded = false;
 
-  // private but used in test
   @state() filterText = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   static override get styles() {
     return [
       fontStyles,
@@ -93,6 +95,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+  }
+
   override render() {
     return html`
       <header>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index ea6e122..4abfeff 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -49,10 +49,7 @@
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
 import {fire} from '../../../utils/event-util';
-import {
-  ChangeMessageDeletedEventDetail,
-  ReplyEvent,
-} from '../../../types/events';
+import {ChangeMessageDeletedEventDetail} from '../../../types/events';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -63,8 +60,6 @@
   interface HTMLElementEventMap {
     'message-anchor-tap': CustomEvent<MessageAnchorTapDetail>;
     'change-message-deleted': CustomEvent<ChangeMessageDeletedEventDetail>;
-    // prettier-ignore
-    'reply': ReplyEvent;
   }
 }
 
@@ -207,15 +202,11 @@
           margin: 0 -4px;
         }
         .collapsed gr-thread-list,
-        .collapsed .replyBtn,
         .collapsed .deleteBtn,
         .collapsed .hideOnCollapsed,
         .hideOnOpen {
           display: none;
         }
-        .replyBtn {
-          margin-right: var(--spacing-m);
-        }
         .collapsed .hideOnOpen {
           display: block;
         }
@@ -442,24 +433,18 @@
   }
 
   private renderActionContainer() {
-    if (!this.computeShowReplyButton()) return nothing;
+    if (!this.isAdmin || !this.loggedIn || this.computeIsAutomated()) {
+      return nothing;
+    }
     return html` <div class="replyActionContainer">
-      <gr-button class="replyBtn" link="" @click=${this.handleReplyTap}>
-        Reply
+      <gr-button
+        ?disabled=${this.isDeletingChangeMsg}
+        class="deleteBtn"
+        link=""
+        @click=${this.handleDeleteMessage}
+      >
+        Delete
       </gr-button>
-      ${when(
-        this.isAdmin,
-        () => html`
-          <gr-button
-            ?disabled=${this.isDeletingChangeMsg}
-            class="deleteBtn"
-            link=""
-            @click=${this.handleDeleteMessage}
-          >
-            Delete
-          </gr-button>
-        `
-      )}
     </div>`;
   }
 
@@ -697,16 +682,6 @@
     );
   }
 
-  // private but used in tests.
-  computeShowReplyButton() {
-    return (
-      !!this.message &&
-      !!this.message.message &&
-      this.loggedIn &&
-      !this.computeIsAutomated()
-    );
-  }
-
   private handleClick(e: Event) {
     if (!this.message || this.message?.expanded) {
       return;
@@ -757,12 +732,6 @@
     fire(this, 'message-anchor-tap', detail);
   }
 
-  private handleReplyTap(e: Event) {
-    e.preventDefault();
-    // TODO: Fix the type casting. Might actually be a bug.
-    fire(this, 'reply', {message: this.message as ChangeMessage});
-  }
-
   private handleDeleteMessage(e: Event) {
     e.preventDefault();
     if (!this.message || !this.message.id || !this.changeNum) return;
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 4142d9f..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
@@ -23,7 +23,6 @@
   query,
   queryAndAssert,
   stubRestApi,
-  waitEventLoop,
 } from '../../../test/test-utils';
 import {GrMessage} from './gr-message';
 import {
@@ -35,11 +34,9 @@
   ReviewInputTag,
   Timestamp,
   UrlEncodedCommentId,
+  SavingState,
 } from '../../../types/common';
-import {
-  ChangeMessageDeletedEventDetail,
-  ReplyEventDetail,
-} from '../../../types/events';
+import {ChangeMessageDeletedEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
 import {SinonStubbedMember} from 'sinon';
@@ -56,32 +53,6 @@
       element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('reply event', async () => {
-      element.message = {
-        ...createChangeMessage(),
-        id: '47c43261_55aa2c41' as ChangeMessageId,
-        author: {
-          _account_id: 1115495 as AccountId,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org' as EmailAddress,
-        },
-        date: '2016-01-12 20:24:49.448000000' as Timestamp,
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1 as RevisionPatchSetNum,
-        expanded: true,
-      };
-
-      const promise = mockPromise();
-      element.addEventListener('reply', (e: CustomEvent<ReplyEventDetail>) => {
-        assert.deepEqual(e.detail.message, element.message);
-        promise.resolve();
-      });
-      await waitEventLoop();
-      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
-      queryAndAssert<GrButton>(element, '.replyBtn').click();
-      await promise;
-    });
-
     test('can see delete button', async () => {
       element.message = {
         ...createChangeMessage(),
@@ -371,18 +342,6 @@
       assert.shadowDom.equal(element, rendered);
     });
 
-    test('reply button hidden unless logged in', () => {
-      element.message = {
-        ...createChangeMessage(),
-        message: 'Uploaded patch set 1.',
-        expanded: false,
-      };
-      element.loggedIn = false;
-      assert.isFalse(element.computeShowReplyButton());
-      element.loggedIn = true;
-      assert.isTrue(element.computeShowReplyButton());
-    });
-
     test('_computeShowOnBehalfOf', () => {
       const message = {
         ...createChangeMessage(),
@@ -817,7 +776,7 @@
               message: 'n',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              __draft: true,
+              savingState: SavingState.OK,
             },
           ],
           patchNum: 1 as RevisionPatchSetNum,
@@ -833,59 +792,4 @@
       );
     });
   });
-
-  suite('when logged in but not admin', () => {
-    setup(async () => {
-      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
-    });
-
-    test('can see reply but not delete button', async () => {
-      element.message = {
-        ...createChangeMessage(),
-        id: '47c43261_55aa2c41' as ChangeMessageId,
-        author: {
-          _account_id: 1115495 as AccountId,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org' as EmailAddress,
-        },
-        date: '2016-01-12 20:24:49.448000000' as Timestamp,
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1 as RevisionPatchSetNum,
-        expanded: true,
-      };
-      await element.updateComplete;
-
-      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
-      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
-    });
-
-    test('reply button shown when message is updated', async () => {
-      element.message = undefined;
-      await element.updateComplete;
-
-      let replyEl = query(element, '.replyActionContainer');
-      // We don't even expect the button to show up in the DOM when the message
-      // is undefined.
-      assert.isNotOk(replyEl);
-
-      element.message = {
-        ...createChangeMessage(),
-        id: '47c43261_55aa2c41' as ChangeMessageId,
-        author: {
-          _account_id: 1115495 as AccountId,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org' as EmailAddress,
-        },
-        date: '2016-01-12 20:24:49.448000000' as Timestamp,
-        message: 'not empty',
-        _revision_number: 1 as RevisionPatchSetNum,
-        expanded: true,
-      };
-      await element.updateComplete;
-
-      replyEl = queryAndAssert(element, '.replyActionContainer');
-      assert.isOk(replyEl);
-    });
-  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 345feb2..d5da9c9 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -46,6 +46,7 @@
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
 import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text';
+import {waitUntil} from '../../../utils/async-util';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -348,7 +349,7 @@
     super();
     subscribe(
       this,
-      () => this.getCommentsModel().threads$,
+      () => this.getCommentsModel().threadsSaved$,
       x => {
         this.commentThreads = x;
       }
@@ -439,6 +440,9 @@
   }
 
   async scrollToMessage(messageID: string) {
+    await waitUntil(() => this.messages && this.messages.length > 0);
+    await this.updateComplete;
+
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index ec43aea..df04f68 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -27,7 +27,7 @@
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assertIsDefined, uuid} from '../../../utils/common-util';
 import {html} from 'lit';
 import {fixture, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -61,9 +61,9 @@
     email: 'andybons@chromium.org' as EmailAddress,
   };
   return {
-    id: (params.id || Math.random().toString()) as ChangeMessageId,
+    id: (params.id || uuid()) as ChangeMessageId,
     date: (params.date || '2016-01-12 20:28:33.038000') as Timestamp,
-    message: params.message || Math.random().toString(),
+    message: params.message || uuid(),
     _revision_number: (params._revision_number || 1) as PatchSetNum,
     author: params.author || author1,
     tag: params.tag,
@@ -195,9 +195,9 @@
       }
     });
 
-    test('expand/collapse from external keypress', () => {
+    test('expand/collapse from external keypress', async () => {
       // Start with one expanded message. -> not all collapsed
-      element.scrollToMessage(messages[1].id);
+      await element.scrollToMessage(messages[1].id);
       assert.isFalse(
         [...getMessages()].filter(m => m.message?.expanded).length === 0
       );
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 05acb29..2ba0cf8 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -11,31 +11,26 @@
 import '../../shared/gr-icon/gr-icon';
 import {classMap} from 'lit/directives/class-map.js';
 import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   ChangeInfo,
   CommitId,
   PatchSetNumber,
   RelatedChangeAndCommitInfo,
-  RelatedChangesInfo,
   RevisionPatchSetNum,
   SubmittedTogetherInfo,
 } from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
-import {
-  changeIsOpen,
-  getChangeNumber,
-  getRevisionKey,
-} from '../../../utils/change-util';
+import {getChangeNumber, getRevisionKey} from '../../../utils/change-util';
 import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
 import {createChangeUrl} from '../../../models/views/change';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 export interface ChangeMarkersInList {
   showCurrentChangeArrow: boolean;
@@ -54,9 +49,6 @@
 
 @customElement('gr-related-changes-list')
 export class GrRelatedChangesList extends LitElement {
-  @property({type: Boolean})
-  mergeable?: boolean;
-
   @state()
   change?: ParsedChangeInfo;
 
@@ -81,10 +73,13 @@
   @state()
   sameTopicChanges: ChangeInfo[] = [];
 
-  private readonly restApiService = getAppContext().restApiService;
-
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
     subscribe(
@@ -97,6 +92,31 @@
       () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().relatedChanges$,
+      x => (this.relatedChanges = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().submittedTogether$,
+      x => (this.submittedTogether = x)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().cherryPicks$,
+      x => (this.cherryPickChanges = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().conflictingChanges$,
+      x => (this.conflictingChanges = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().sameTopicChanges$,
+      x => (this.sameTopicChanges = x ?? [])
+    );
   }
 
   static override get styles() {
@@ -297,7 +317,7 @@
             >
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
-              )}${this.renderSubmittedTogetherLine(change, true)}
+              )}${this.renderSubmittedTogetherLine(change)}
             </div>`
         )}
       </gr-related-collapse>
@@ -307,17 +327,14 @@
     </section>`;
   }
 
-  private renderSubmittedTogetherLine(
-    change: ChangeInfo,
-    showSubmittabilityCheck: boolean
-  ) {
+  private renderSubmittedTogetherLine(change: ChangeInfo) {
     const truncatedRepo = truncatePath(change.project, 2);
     return html`
       <gr-related-change
         .label=${this.renderChangeTitle(change)}
         .change=${change}
         .href=${createChangeUrl({change, usp: 'submitted-together'})}
-        ?show-submittable-check=${showSubmittabilityCheck}
+        show-submittable-check
         >${change.subject}</gr-related-change
       >
       <span class="repo" .title=${change.project}>${truncatedRepo}</span
@@ -356,7 +373,7 @@
             >
               ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
-              )}${this.renderSubmittedTogetherLine(change, false)}
+              )}${this.renderSubmittedTogetherLine(change)}
             </div>`
         )}
       </gr-related-collapse>
@@ -586,72 +603,6 @@
     return html`<span class="marker space"></span>`;
   }
 
-  reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
-    const change = this.change;
-    if (!change) return Promise.reject(new Error('change missing'));
-    if (!this.latestPatchNum)
-      return Promise.reject(new Error('latestPatchNum missing'));
-    if (!getRelatedChanges) {
-      getRelatedChanges = this.restApiService.getRelatedChanges(
-        change._number,
-        this.latestPatchNum
-      );
-    }
-    const promises: Array<Promise<void>> = [
-      getRelatedChanges.then(response => {
-        if (!response) {
-          throw new Error('getRelatedChanges returned undefined response');
-        }
-        this.relatedChanges = response?.changes ?? [];
-      }),
-      this.restApiService
-        .getChangesSubmittedTogether(change._number)
-        .then(response => {
-          this.submittedTogether = response;
-        }),
-      this.restApiService
-        .getChangeCherryPicks(change.project, change.change_id, change._number)
-        .then(response => {
-          this.cherryPickChanges = response || [];
-        }),
-    ];
-
-    // Get conflicts if change is open and is mergeable.
-    // Mergeable is output of restApiServict.getMergeable from gr-change-view
-    if (changeIsOpen(change) && this.mergeable) {
-      promises.push(
-        this.restApiService
-          .getChangeConflicts(change._number)
-          .then(response => {
-            this.conflictingChanges = response ?? [];
-          })
-      );
-    }
-    if (change.topic) {
-      const changeTopic = change.topic;
-      promises.push(
-        this.restApiService.getConfig().then(config => {
-          if (config && !config.change.submit_whole_topic) {
-            return this.restApiService
-              .getChangesWithSameTopic(changeTopic, {
-                openChangesOnly: true,
-                changeToExclude: change._number,
-              })
-              .then(response => {
-                if (changeTopic === this.change?.topic) {
-                  this.sameTopicChanges = response ?? [];
-                }
-              });
-          }
-          this.sameTopicChanges = [];
-          return Promise.resolve();
-        })
-      );
-    }
-
-    return Promise.all(promises);
-  }
-
   /**
    * Do the given objects describe the same change? Compares the changes by
    * their numbers.
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 571b5b5..24a8217 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -4,10 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStubbedMember} from 'sinon';
 import {PluginApi} from '../../../api/plugin';
-import {ChangeStatus} from '../../../constants/constants';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import '../../../test/common-test-setup';
 import {testResolver} from '../../../test/common-test-setup';
 import {
@@ -19,12 +16,7 @@
   createRevision,
   createSubmittedTogetherInfo,
 } from '../../../test/test-data-generators';
-import {
-  query,
-  queryAndAssert,
-  stubRestApi,
-  waitEventLoop,
-} from '../../../test/test-utils';
+import {query, queryAndAssert, waitEventLoop} from '../../../test/test-utils';
 import {
   ChangeId,
   ChangeInfo,
@@ -196,16 +188,10 @@
     });
 
     test('render', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(submittedTogether)
-      );
-      stubRestApi('getChangeCherryPicks').returns(
-        Promise.resolve([createChange()])
-      );
-      await element.reload();
+      element.relatedChanges = relatedChangeInfo.changes;
+      element.submittedTogether = submittedTogether;
+      element.cherryPickChanges = [createChange()];
+      await element.updateComplete;
 
       assert.shadowDom.equal(
         element,
@@ -262,10 +248,9 @@
     });
 
     test('first list', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      await element.reload();
+      element.relatedChanges = relatedChangeInfo.changes;
+      await element.updateComplete;
+
       const section = queryAndAssert<HTMLElement>(element, '#relatedChanges');
       const relatedChanges = queryAndAssert<GrRelatedCollapse>(
         section,
@@ -275,13 +260,10 @@
     });
 
     test('first empty second non-empty', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(createRelatedChangesInfo())
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(submittedTogether)
-      );
-      await element.reload();
+      element.relatedChanges = createRelatedChangesInfo().changes;
+      element.submittedTogether = submittedTogether;
+      await element.updateComplete;
+
       const relatedChanges = query<HTMLElement>(element, '#relatedChanges');
       assert.notExists(relatedChanges);
       const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
@@ -292,16 +274,10 @@
     });
 
     test('first non-empty second empty third non-empty', async () => {
-      stubRestApi('getRelatedChanges').returns(
-        Promise.resolve(relatedChangeInfo)
-      );
-      stubRestApi('getChangesSubmittedTogether').returns(
-        Promise.resolve(createSubmittedTogetherInfo())
-      );
-      stubRestApi('getChangeCherryPicks').returns(
-        Promise.resolve([createChange()])
-      );
-      await element.reload();
+      element.relatedChanges = relatedChangeInfo.changes;
+      element.submittedTogether = createSubmittedTogetherInfo();
+      element.cherryPickChanges = [createChange()];
+      await element.updateComplete;
 
       const relatedChanges = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#relatedChanges'),
@@ -364,67 +340,6 @@
     assert.equal(getChangeNumber(change2), 1);
   });
 
-  suite('get conflicts tests', () => {
-    let element: GrRelatedChangesList;
-    let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
-
-    setup(async () => {
-      element = await fixture(
-        html`<gr-related-changes-list></gr-related-changes-list>`
-      );
-      conflictsStub = stubRestApi('getChangeConflicts').returns(
-        Promise.resolve(undefined)
-      );
-    });
-
-    test('request conflicts if open and mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.isTrue(conflictsStub.called);
-    });
-
-    test('does not request conflicts if closed and mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('does not request conflicts if open and not mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('doesnt request conflicts if closed and not mergeable', () => {
-      element.latestPatchNum = 7 as PatchSetNumber;
-      element.change = {
-        ...createParsedChange(),
-        change_id: '123' as ChangeId,
-        status: ChangeStatus.NEW,
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-  });
-
   test('connected revisions', () => {
     const change: ParsedChangeInfo = {
       ...createParsedChange(),
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
index a20135b..4f951f3 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
@@ -25,6 +25,8 @@
 import {testResolver} from '../../../test/common-test-setup';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {createNewPatchsetLevel} from '../../../utils/comment-util';
+import {commentsModelToken} from '../../../models/comments/comments-model';
 
 suite('gr-reply-dialog-it tests', () => {
   let element: GrReplyDialog;
@@ -62,6 +64,9 @@
       'Code-Review': ['-1', ' 0', '+1'],
       Verified: ['-1', ' 0', '+1'],
     };
+    testResolver(commentsModelToken).addNewDraft(
+      createNewPatchsetLevel(latestPatchNum, '', false)
+    );
   };
 
   setup(async () => {
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 7c85bb6..f41236b 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
@@ -58,7 +58,6 @@
   SuggestedReviewerGroupInfo,
   Suggestion,
   UserId,
-  UnsavedInfo,
   isDraft,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -72,7 +71,6 @@
   queryAndAssert,
 } from '../../../utils/common-util';
 import {
-  createPatchsetLevelUnsavedDraft,
   getFirstComment,
   isPatchsetLevel,
   isUnresolved,
@@ -90,7 +88,6 @@
   fire,
   fireNoBubble,
   fireIronAnnounce,
-  fireReload,
   fireServerError,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
@@ -335,7 +332,7 @@
   patchsetLevelDraftIsResolved = true;
 
   @state()
-  patchsetLevelComment?: UnsavedInfo | DraftInfo;
+  patchsetLevelComment?: DraftInfo;
 
   @state()
   isOwner = false;
@@ -643,7 +640,7 @@
     );
     subscribe(
       this,
-      () => this.getCommentsModel().draftThreads$,
+      () => this.getCommentsModel().draftThreadsSaved$,
       threads =>
         (this.draftCommentThreads = threads.filter(
           t => !(isDraft(getFirstComment(t)) && isPatchsetLevel(t))
@@ -894,12 +891,7 @@
   }
 
   private renderPatchsetLevelComment() {
-    if (!this.patchsetLevelComment)
-      this.patchsetLevelComment = createPatchsetLevelUnsavedDraft(
-        this.latestPatchNum,
-        this.patchsetLevelDraftMessage,
-        !this.patchsetLevelDraftIsResolved
-      );
+    if (!this.patchsetLevelComment) return nothing;
     return html`
       <gr-comment
         id="patchsetLevelComment"
@@ -941,7 +933,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">
@@ -952,17 +945,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>
           `
         )}
@@ -1229,7 +1218,7 @@
    * change view for initializing the dialog after opening the overlay. Maybe it
    * should be called `onOpened()` or `initialize()`?
    */
-  open(focusTarget?: FocusTarget, quote?: string) {
+  open(focusTarget?: FocusTarget) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
     this.getChangeModel()
@@ -1241,10 +1230,6 @@
       });
 
     this.focusOn(focusTarget);
-    if (quote?.length) {
-      // If a reply quote has been provided, use it.
-      this.patchsetLevelDraftMessage = quote;
-    }
     if (this.restApiService.hasPendingDiffDrafts()) {
       this.savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
@@ -1352,6 +1337,7 @@
 
   // visible for testing
   async send(includeComments: boolean, startReview: boolean) {
+    // The change model will end this timing when the change was reloaded.
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.getLabelScores().getLabelValues();
 
@@ -1515,10 +1501,6 @@
     });
   }
 
-  computeHideDraftList(draftCommentThreads?: CommentThread[]) {
-    return !draftCommentThreads || draftCommentThreads.length === 0;
-  }
-
   computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
     return pluralize(total, 'Draft');
@@ -1947,7 +1929,7 @@
   }
 
   _reload() {
-    fireReload(this, true);
+    this.getChangeModel().navigateToChangeResetReload();
     this.cancel();
   }
 
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..c4a978c 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';
@@ -70,6 +69,7 @@
   commentsModelToken,
 } from '../../../models/comments/comments-model';
 import {isOwner} from '../../../utils/change-util';
+import {createNewPatchsetLevel} from '../../../utils/comment-util';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -154,9 +154,12 @@
       Verified: ['-1', ' 0', '+1'],
     };
     element.draftCommentThreads = [];
+    commentsModel = testResolver(commentsModelToken);
+    commentsModel.addNewDraft(
+      createNewPatchsetLevel(latestPatchNum, '', false)
+    );
 
     await element.updateComplete;
-    commentsModel = testResolver(commentsModelToken);
   });
 
   function stubSaveReview(
@@ -479,9 +482,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-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 2766114..98f65c0 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -327,7 +327,7 @@
         review
       )
       .then(() => {
-        fireReload(this, true);
+        fireReload(this);
       });
   }
 
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 b3f2919..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,6 +34,7 @@
   RevisionPatchSetNum,
   CommentThread,
   isDraft,
+  SavingState,
 } from '../../../types/common';
 import {query, queryAndAssert} from '../../../utils/common-util';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
@@ -75,7 +76,7 @@
             updated: '2015-12-01 15:16:15.000000000' as Timestamp,
             message: 'draft',
             unresolved: true,
-            __draft: true,
+            savingState: SavingState.OK,
             patch_set: '2' as RevisionPatchSetNum,
           },
         ],
@@ -162,7 +163,7 @@
             updated: '2015-12-05 15:16:15.000000000' as Timestamp,
             message: 'resolved draft',
             unresolved: false,
-            __draft: true,
+            savingState: SavingState.OK,
             patch_set: '2' as RevisionPatchSetNum,
           },
         ],
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 4af24cc..94241ea 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -26,4 +26,22 @@
    * page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string): void;
+
+  /**
+   * You can ask the router to block all navigation to other pages for a while,
+   * e.g. while there are unsaved comments. You must make sure to call
+   * `releaseNavigation()` with the same string shortly after to unblock the
+   * router.
+   *
+   * The provided reason must be non-empty.
+   */
+  blockNavigation(reason: string): void;
+
+  /**
+   * See `blockNavigation()`.
+   *
+   * This API is not counting. If you block navigation with the same reason
+   * multiple times, then one release call will unblock.
+   */
+  releaseNavigation(reason: string): void;
 }
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 1d2a272..264c6e0 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -4,6 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
+import {sameOrigin} from '../../../utils/url-util';
+
 /**
  * This file was originally a copy of https://github.com/visionmedia/page.js.
  * It was converted to TypeScript and stripped off lots of code that we don't
@@ -252,17 +254,6 @@
   };
 }
 
-function sameOrigin(href: string) {
-  if (!href) return false;
-  const url = new URL(href, window.location.toString());
-  const loc = window.location;
-  return (
-    loc.protocol === url.protocol &&
-    loc.hostname === url.hostname &&
-    loc.port === url.port
-  );
-}
-
 function samePath(url: HTMLAnchorElement) {
   const loc = window.location;
   return url.pathname === loc.pathname && url.search === loc.search;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 902616e..aa6bb7a4 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -11,7 +11,7 @@
   computeLatestPatchNum,
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
   GroupId,
@@ -99,6 +99,11 @@
 import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
 import {Route, ViewState} from '../../../models/views/base';
 import {Model} from '../../../models/model';
+import {
+  InteractivePromise,
+  interactivePromise,
+  timeoutPromise,
+} from '../../../utils/async-util';
 
 // TODO: Move all patterns to view model files and use the `Route` interface,
 // which will enforce using `RegExp` in its `urlPattern` property.
@@ -291,6 +296,16 @@
 
   private view?: GerritView;
 
+  // While this set is not empty, the router will refuse to navigate to
+  // other pages, but instead show an alert. It will also install a
+  // `beforeUnload` handler that prevents the browser from closing the tab.
+  private navigationBlockers: Set<string> = new Set<string>();
+
+  // While navigationBlockers is not empty, this promise will continuously
+  // check for navigationBlockers to become empty again.
+  // This is undefined, iff navigationBlockers is empty.
+  private navigationBlockerPromise?: InteractivePromise<void>;
+
   readonly page = new Page();
 
   constructor(
@@ -336,12 +351,42 @@
     ];
   }
 
+  blockNavigation(reason: string): void {
+    assert(!!reason, 'empty reason is not allowed');
+    this.navigationBlockers.add(reason);
+    if (this.navigationBlockers.size === 1) {
+      this.navigationBlockerPromise = interactivePromise();
+      window.addEventListener('beforeunload', this.beforeUnloadHandler);
+    }
+  }
+
+  releaseNavigation(reason: string): void {
+    assert(!!reason, 'empty reason is not allowed');
+    this.navigationBlockers.delete(reason);
+    if (this.navigationBlockers.size === 0) {
+      window.removeEventListener('beforeunload', this.beforeUnloadHandler);
+      this.navigationBlockerPromise?.resolve();
+    }
+  }
+
+  private beforeUnloadHandler = (event: BeforeUnloadEvent) => {
+    const reason = [...this.navigationBlockers][0];
+    if (!reason) return;
+
+    event.preventDefault(); // Cancel the event (per the standard).
+    event.returnValue = reason; // Chrome requires returnValue to be set.
+    // Note that we could as well just use '' instead of `reason`. Browsers will
+    // just show a generic message anyway.
+    return reason;
+  };
+
   finalize(): void {
     for (const subscription of this.subscriptions) {
       subscription.unsubscribe();
     }
     this.subscriptions = [];
     this.page.stop();
+    window.removeEventListener('beforeunload', this.beforeUnloadHandler);
   }
 
   start() {
@@ -589,6 +634,30 @@
       next();
     });
 
+    // Block navigation while navigationBlockers exist. But wait 1 second for
+    // those blockers to resolve. If they do, then still navigate. We don't want
+    // to annoy users by forcing them to navigate twice only because it took
+    // another 200ms for a comment to save or something similar.
+    this.page.registerRoute(/(.*)/, (_, next) => {
+      if (this.navigationBlockers.size === 0) {
+        next();
+        return;
+      }
+
+      const msg = 'Waiting 1 second for navigation blockers to resolve ...';
+      fireAlert(document, msg);
+      Promise.race([this.navigationBlockerPromise, timeoutPromise(1000)]).then(
+        () => {
+          if (this.navigationBlockers.size === 0) {
+            next();
+          } else {
+            const reason = [...this.navigationBlockers][0];
+            fireAlert(document, `Navigation is blocked by: ${reason}`);
+          }
+        }
+      );
+    });
+
     // Middleware
     this.page.registerRoute(/(.*)/, (ctx, next) => {
       document.body.scrollTop = 0;
@@ -1257,10 +1326,10 @@
     };
 
     const queryMap = new URLSearchParams(ctx.querystring);
-    if (queryMap.has('forceReload')) state.forceReload = true;
     if (queryMap.has('openReplyDialog')) state.openReplyDialog = true;
 
     const tab = queryMap.get('tab');
+    if (queryMap.has('forceReload')) state.forceReload = true;
     if (tab) state.tab = tab;
     const checksPatchset = Number(queryMap.get('checksPatchset'));
     if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
@@ -1353,6 +1422,8 @@
       view: GerritView.CHANGE,
       childView: ChangeChildView.OVERVIEW,
     };
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
     assertIsDefined(state.repo);
     this.reporting.setRepoName(state.repo);
     this.reporting.setChangeId(changeNum);
@@ -1374,6 +1445,8 @@
       childView: ChangeChildView.DIFF,
       diffView: {path: ctx.params[8]},
     };
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
       state.diffView!.leftSide = address.leftSide;
@@ -1424,6 +1497,8 @@
       childView: ChangeChildView.EDIT,
       editView: {path: ctx.params[3], lineNum: Number(ctx.hash)},
     };
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
@@ -1447,14 +1522,7 @@
     };
     const tab = queryMap.get('tab');
     if (tab) state.tab = tab;
-    if (queryMap.has('forceReload')) {
-      state.forceReload = true;
-      history.replaceState(
-        null,
-        '',
-        location.href.replace(/[?&]forceReload=true/, '')
-      );
-    }
+    if (queryMap.has('forceReload')) state.forceReload = true;
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index c4cb465..234bf95 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -11,6 +11,8 @@
   stubRestApi,
   addListenerForTest,
   waitUntilCalled,
+  mockPromise,
+  MockPromise,
 } from '../../../test/test-utils';
 import {GrRouter, routerToken} from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
@@ -254,6 +256,80 @@
     });
   });
 
+  suite('navigation blockers', () => {
+    let clock: sinon.SinonFakeTimers;
+    let redirectStub: sinon.SinonStub;
+    let urlPromise: MockPromise<string>;
+
+    setup(() => {
+      stubRestApi('setInProjectLookup');
+      urlPromise = mockPromise<string>();
+      redirectStub = sinon
+        .stub(router, 'redirect')
+        .callsFake(urlPromise.resolve);
+      router._testOnly_startRouter();
+      clock = sinon.useFakeTimers();
+    });
+
+    test('no blockers: normal redirect', async () => {
+      router.page.show('/settings/agreements');
+      const url = await urlPromise;
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(url, '/settings/#Agreements');
+    });
+
+    test('redirect blocked', async () => {
+      const firstAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', firstAlertPromise.resolve);
+
+      router.blockNavigation('a good reason');
+      router.page.show('/settings/agreements');
+
+      const firstAlert = (await firstAlertPromise) as CustomEvent;
+      assert.equal(
+        firstAlert.detail.message,
+        'Waiting 1 second for navigation blockers to resolve ...'
+      );
+
+      const secondAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', secondAlertPromise.resolve);
+
+      clock.tick(2000);
+
+      const secondAlert = (await secondAlertPromise) as CustomEvent;
+      assert.equal(
+        secondAlert.detail.message,
+        'Navigation is blocked by: a good reason'
+      );
+
+      assert.isFalse(redirectStub.called);
+    });
+
+    test('redirect blocked, but resolved within one second', async () => {
+      const firstAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', firstAlertPromise.resolve);
+
+      router.blockNavigation('a good reason');
+      router.page.show('/settings/agreements');
+
+      const firstAlert = (await firstAlertPromise) as CustomEvent;
+      assert.equal(
+        firstAlert.detail.message,
+        'Waiting 1 second for navigation blockers to resolve ...'
+      );
+
+      const secondAlertPromise = mockPromise<Event>();
+      addListenerForTest(document, 'show-alert', secondAlertPromise.resolve);
+
+      clock.tick(500);
+      router.releaseNavigation('a good reason');
+      clock.tick(2000);
+
+      await urlPromise;
+      assert.isTrue(redirectStub.calledOnce);
+    });
+  });
+
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index b97f54f..4f1ce7a 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -24,7 +24,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
 import {assert} from '../../../utils/common-util';
@@ -38,6 +38,8 @@
 import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
 import {fireReload} from '../../../utils/event-util';
 import {when} from 'lit/directives/when.js';
+import {Timing} from '../../../constants/reporting';
+import {changeModelToken} from '../../../models/change/change-model';
 
 interface FilePreview {
   filepath: string;
@@ -61,10 +63,10 @@
   @query('#nextFix')
   nextFix?: GrButton;
 
-  @property({type: Object})
+  @state()
   change?: ParsedChangeInfo;
 
-  @property({type: Number})
+  @state()
   changeNum?: NumericChangeId;
 
   @state()
@@ -101,8 +103,12 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -130,6 +136,16 @@
         this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
   }
 
   static override styles = [
@@ -154,6 +170,14 @@
         align-items: center;
         margin-right: var(--spacing-l);
       }
+      .info {
+        background-color: var(--info-background);
+        padding: var(--spacing-l) var(--spacing-xl);
+      }
+      .info gr-icon {
+        color: var(--selected-foreground);
+        margin-right: var(--spacing-xl);
+      }
     `,
   ];
 
@@ -246,7 +270,9 @@
 
   private renderWarning(message: string) {
     if (!message) return nothing;
-    return html`<span><gr-icon icon="info"></gr-icon>${message}</span>`;
+    return html`<span class="info"
+      ><gr-icon icon="info"></gr-icon>${message}</span
+    >`;
   }
 
   /**
@@ -266,7 +292,9 @@
   private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
     this.currentFix = fixSuggestion;
     this.loading = true;
+    this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     await this.fetchFixPreview(fixSuggestion);
+    this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD);
     this.loading = false;
   }
 
@@ -376,6 +404,7 @@
       throw new Error('Not all required properties are set.');
     }
     this.isApplyFixLoading = true;
+    this.reporting.time(Timing.APPLY_FIX_LOAD);
     let res;
     if (this.fixSuggestions?.[0].fix_id === PROVIDED_FIX_ID) {
       res = await this.restApiService.applyFixSuggestion(
@@ -401,6 +430,7 @@
       this.close(true);
     }
     this.isApplyFixLoading = false;
+    this.reporting.timeEnd(Timing.APPLY_FIX_LOAD);
   }
 }
 
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 6567e33..00e013b 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
@@ -7,14 +7,13 @@
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
-  PathToCommentsInfoMap,
   FileInfo,
   PARENT,
-  CommentInfo,
   CommentThread,
   Comment,
   CommentMap,
   DraftInfo,
+  CommentInfo,
 } from '../../../types/common';
 import {
   isUnresolved,
@@ -23,6 +22,7 @@
   isDraftThread,
   isPatchsetLevel,
   addPath,
+  id,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {CommentSide} from '../../../constants/constants';
@@ -31,22 +31,22 @@
 
 // TODO: Move file out of elements/ directory
 export class ChangeComments {
-  private readonly _comments: PathToCommentsInfoMap;
+  private readonly _comments: {[path: string]: CommentInfo[]};
 
   private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
 
   private readonly _drafts: {[path: string]: DraftInfo[]};
 
-  private readonly _portedComments: PathToCommentsInfoMap;
+  private readonly _portedComments: {[path: string]: CommentInfo[]};
 
-  private readonly _portedDrafts: PathToCommentsInfoMap;
+  private readonly _portedDrafts: {[path: string]: DraftInfo[]};
 
   constructor(
-    comments?: PathToCommentsInfoMap,
+    comments?: {[path: string]: CommentInfo[]},
     robotComments?: {[path: string]: RobotCommentInfo[]},
     drafts?: {[path: string]: DraftInfo[]},
-    portedComments?: PathToCommentsInfoMap,
-    portedDrafts?: PathToCommentsInfoMap
+    portedComments?: {[path: string]: CommentInfo[]},
+    portedDrafts?: {[path: string]: DraftInfo[]}
   ) {
     this._comments = addPath(comments);
     this._robotComments = addPath(robotComments);
@@ -102,7 +102,7 @@
    */
   getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const publishedComments: {[path: string]: CommentInfo[]} = {};
+    const publishedComments: {[path: string]: Comment[]} = {};
     for (const path of Object.keys(paths)) {
       publishedComments[path] = this.getAllCommentsForPath(
         path,
@@ -136,8 +136,8 @@
     path: string,
     patchNum?: PatchSetNum,
     includeDrafts?: boolean
-  ): CommentInfo[] {
-    const comments: CommentInfo[] = this._comments[path] || [];
+  ): Comment[] {
+    const comments: Comment[] = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (includeDrafts) {
@@ -192,7 +192,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    */
-  getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
+  getAllDraftsForFile(file: PatchSetFile): DraftInfo[] {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
     if (file.basePath) {
       allDrafts = allDrafts.concat(
@@ -210,8 +210,8 @@
    * @param patchRange The patch-range object containing patchNum
    * and basePatchNum properties to represent the range.
    */
-  getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
-    let comments: CommentInfo[] = [];
+  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
+    let comments: Comment[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
     if (this._comments && this._comments[path]) {
@@ -269,7 +269,7 @@
     file: PatchSetFile,
     patchRange: PatchRange
   ): CommentThread[] {
-    const portedComments = this._portedComments[file.path] || [];
+    const portedComments: Comment[] = this._portedComments[file.path] || [];
     portedComments.push(...(this._portedDrafts[file.path] || []));
     if (file.basePath) {
       portedComments.push(...(this._portedComments[file.basePath] || []));
@@ -281,7 +281,7 @@
     // ported comments will involve comments that may not belong to the
     // current patchrange, so we need to form threads for them using all
     // comments
-    const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
+    const allComments: Comment[] = this.getAllCommentsForFile(file, true);
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
@@ -289,12 +289,12 @@
       // have the root comment of the thread not be ported, hence loop over
       // entire thread
       const portedComment = portedComments.find(portedComment =>
-        thread.comments.some(c => portedComment.id === c.id)
+        thread.comments.some(c => id(portedComment) === id(c))
       );
       if (!portedComment) return false;
 
       const originalComment = thread.comments.find(
-        comment => comment.id === portedComment.id
+        comment => id(comment) === id(portedComment)
       )!;
 
       // Original comment shown anyway? No need to port.
@@ -347,10 +347,7 @@
    * @param patchRange The patch-range object containing patchNum
    * and basePatchNum properties to represent the range.
    */
-  getCommentsForFile(
-    file: PatchSetFile,
-    patchRange: PatchRange
-  ): CommentInfo[] {
+  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
     const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
       comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -372,11 +369,11 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: CommentInfo[] = [];
+    let comments: Comment[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
     } else {
-      comments = this._commentObjToArray<CommentInfo>(
+      comments = this._commentObjToArray<Comment>(
         this.getAllPublishedComments(file.patchNum)
       );
     }
@@ -500,8 +497,8 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: CommentInfo[] = [];
-    let drafts: CommentInfo[] = [];
+    let comments: Comment[] = [];
+    let drafts: Comment[] = [];
 
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
index d337521..3e36ab5 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -29,7 +29,6 @@
   PARENT,
   PatchRange,
   PatchSetNum,
-  PathToCommentsInfoMap,
   RevisionPatchSetNum,
   RobotCommentInfo,
   Timestamp,
@@ -49,7 +48,7 @@
     });
 
     suite('ported comments', () => {
-      let portedComments: PathToCommentsInfoMap;
+      let portedComments: {[path: string]: CommentInfo[]};
       const comment1: CommentInfo = {
         ...createComment(),
         unresolved: true,
@@ -593,7 +592,7 @@
       const robotComments: {[path: string]: RobotCommentInfo[]} = {
         'file/one': [comments[0], comments[1]],
       };
-      const commentsByFile: PathToCommentsInfoMap = {
+      const commentsByFile: {[path: string]: CommentInfo[]} = {
         'file/one': [comments[2], comments[3]],
         'file/two': [comments[4], comments[5]],
         'file/three': [comments[6], comments[7], comments[8]],
@@ -741,7 +740,7 @@
       });
 
       test('computeUnresolvedNum w/ non-linear thread', () => {
-        const comments: PathToCommentsInfoMap = {
+        const comments: {[path: string]: CommentInfo[]} = {
           path: [
             {
               id: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
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 9bead95..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 {
-  equalLocation,
+  createNew,
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
@@ -31,6 +31,7 @@
   BlameInfo,
   ChangeInfo,
   CommentThread,
+  DraftInfo,
   EDIT,
   NumericChangeId,
   PARENT,
@@ -1044,15 +1045,10 @@
 
   private threadsChanged(threads: CommentThread[]) {
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
-    const unsavedThreadEls: GrCommentThread[] = [];
     const threadEls = this.getThreadEls();
     for (const threadEl of threadEls) {
-      if (threadEl.rootId) {
-        rootIdToThreadEl.set(threadEl.rootId, threadEl);
-      } else {
-        // Unsaved thread els must have editing:true, just being defensive here.
-        if (threadEl.editing) unsavedThreadEls.push(threadEl);
-      }
+      assertIsDefined(threadEl.rootId, 'threadEl.rootId');
+      rootIdToThreadEl.set(threadEl.rootId, threadEl);
     }
     const dontRemove = new Set<GrCommentThread>();
     const threadCount = threads.length;
@@ -1062,23 +1058,8 @@
     for (const thread of threads) {
       // Let's find an existing DOM element matching the thread. Normally this
       // is as simple as matching the rootIds.
-      let existingThreadEl =
+      const existingThreadEl =
         thread.rootId && rootIdToThreadEl.get(thread.rootId);
-      // But unsaved threads don't have rootIds. The incoming thread might be
-      // the saved version of the unsaved thread element. To verify that we
-      // check that the thread only has one comment and that their location is
-      // identical.
-      // TODO(brohlfs): This matching is not perfect. You could quickly create
-      // two new threads on the same line/range. Then this code just makes a
-      // random guess.
-      if (!existingThreadEl && thread.comments?.length === 1) {
-        for (const unsavedThreadEl of unsavedThreadEls) {
-          if (equalLocation(unsavedThreadEl.thread, thread)) {
-            existingThreadEl = unsavedThreadEl;
-            break;
-          }
-        }
-      }
       // There is a case possible where the rootIds match but the locations
       // are different. Such as when a thread was originally attached on the
       // right side of the diff but now should be attached on the left side of
@@ -1105,10 +1086,6 @@
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
       if (dontRemove.has(threadEl)) continue;
-      // The user may have opened a couple of comment boxes for editing. They
-      // might be unsaved and thus not be reflected in `threads` yet, so let's
-      // keep them open.
-      if (threadEl.editing && threadEl.thread?.comments.length === 0) continue;
       threadEl.remove();
     }
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
@@ -1163,19 +1140,16 @@
     assertIsDefined(path, 'path');
 
     const parentIndex = this.computeParentIndex();
-    const newThread: CommentThread = {
-      rootId: undefined,
-      comments: [],
-      patchNum: patchNum as RevisionPatchSetNum,
-      commentSide,
-      // TODO: Maybe just compute from patchRange.base on the fly?
-      mergeParentNum: parentIndex ?? undefined,
+    const draft: DraftInfo = {
+      ...createNew('', true),
+      patch_set: patchNum as RevisionPatchSetNum,
+      side: commentSide,
+      parent: parentIndex ?? undefined,
       path,
-      line: lineNum,
+      line: typeof lineNum === 'number' ? lineNum : undefined,
       range,
     };
-    const el = this.createThreadElement(newThread);
-    this.attachThreadElement(el);
+    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 42b3344..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
@@ -33,6 +33,7 @@
   BasePatchSetNum,
   BlameInfo,
   CommentRange,
+  DraftInfo,
   EDIT,
   ImageInfo,
   NumericChangeId,
@@ -56,6 +57,10 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-diff-host tests', () => {
   let element: GrDiffHost;
@@ -999,7 +1004,12 @@
   });
 
   suite('create-comment', () => {
+    let addDraftSpy: sinon.SinonSpy;
+
     setup(async () => {
+      const commentsModel: CommentsModel = testResolver(commentsModelToken);
+      addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
       account = createAccountDetailWithId(1);
       element.disconnectedCallback();
       element.connectedCallback();
@@ -1017,17 +1027,12 @@
           },
         })
       );
-      assertIsDefined(element.diffElement);
-      let threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
 
-      assert.equal(threads.length, 1);
-      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[0].thread?.range, undefined);
-      assert.equal(threads[0].thread?.patchNum, 1 as RevisionPatchSetNum);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.PARENT);
+      assert.equal(draft1.range, undefined);
+      assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
 
       // Try to fetch a thread with a different range.
       const range = {
@@ -1049,17 +1054,11 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-
-      assert.equal(threads.length, 2);
-      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-      assert.equal(threads[1].thread?.range, range);
-      assert.equal(threads[1].thread?.patchNum, 1 as RevisionPatchSetNum);
+      assert.equal(addDraftSpy.callCount, 2);
+      const draft2: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft2.side, CommentSide.PARENT);
+      assert.equal(draft2.range, range);
+      assert.equal(draft2.patch_set, 1 as RevisionPatchSetNum);
     });
 
     test('should not be on parent if on the right', async () => {
@@ -1074,16 +1073,10 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      assert.equal(threads.length, 1);
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.REVISION);
+      assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
     });
 
     test('should be on parent if right and base is PARENT', () => {
@@ -1097,15 +1090,10 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.PARENT);
+      assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
     });
 
     test('should be on parent if right and base negative', () => {
@@ -1119,15 +1107,11 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.PARENT);
+      assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
+      assert.equal(draft1.parent, 2);
     });
 
     test('should not be on parent otherwise', () => {
@@ -1140,15 +1124,10 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      const threadEl = threads[0];
-
-      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
-      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+      assert.equal(addDraftSpy.callCount, 1);
+      const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+      assert.equal(draft1.side, CommentSide.REVISION);
+      assert.equal(draft1.patch_set, 2 as RevisionPatchSetNum);
     });
 
     test(
@@ -1168,14 +1147,11 @@
           })
         );
 
-        assertIsDefined(element.diffElement);
-        const threads =
-          element.diffElement.querySelectorAll<GrCommentThread>(
-            'gr-comment-thread'
-          );
-        assert.equal(threads.length, 1);
-        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-        assert.equal(threads[0].thread?.path, element.file.basePath);
+        assert.equal(addDraftSpy.callCount, 1);
+        const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+        assert.equal(draft1.side, CommentSide.REVISION);
+        assert.equal(draft1.patch_set, 2 as RevisionPatchSetNum);
+        assert.equal(draft1.path, element.file.basePath);
       }
     );
 
@@ -1196,15 +1172,11 @@
           })
         );
 
-        assertIsDefined(element.diffElement);
-        const threads =
-          element.diffElement.querySelectorAll<GrCommentThread>(
-            'gr-comment-thread'
-          );
-
-        assert.equal(threads.length, 1);
-        assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
-        assert.equal(threads[0].thread?.path, element.file.path);
+        assert.equal(addDraftSpy.callCount, 1);
+        const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+        assert.equal(draft1.side, CommentSide.REVISION);
+        assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
+        assert.equal(draft1.path, element.file.path);
       }
     );
 
@@ -1257,48 +1229,6 @@
       assert.equal(threads.length, 2);
     });
 
-    test('unsaved thread changes to draft', async () => {
-      element.patchRange = createPatchRange(2, 3);
-      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
-      element.threads = [];
-      await element.updateComplete;
-
-      element.dispatchEvent(
-        new CustomEvent('create-comment', {
-          detail: {
-            side: Side.RIGHT,
-            path: element.path,
-            lineNum: 13,
-          },
-        })
-      );
-      await element.updateComplete;
-      assert.equal(element.getThreadEls().length, 1);
-      const threadEl = element.getThreadEls()[0];
-      assert.equal(threadEl.thread?.line, 13);
-      assert.isDefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread?.comments.length, 0);
-
-      const draftThread = createCommentThread([
-        {
-          path: element.path,
-          patch_set: 3 as RevisionPatchSetNum,
-          line: 13,
-          __draft: true,
-        },
-      ]);
-      element.threads = [draftThread];
-      await element.updateComplete;
-
-      // We expect that no additional thread element was created.
-      assert.equal(element.getThreadEls().length, 1);
-      // In fact the thread element must still be the same.
-      assert.equal(element.getThreadEls()[0], threadEl);
-      // But it must have been updated from unsaved to draft:
-      assert.isUndefined(threadEl.unsavedComment);
-      assert.equal(threadEl.thread?.comments.length, 1);
-    });
-
     test(
       'thread should use new file path if first created ' +
         'on patch set (left) but is base',
@@ -1316,15 +1246,11 @@
           })
         );
 
-        assertIsDefined(element.diffElement);
-        const threads =
-          element.diffElement.querySelectorAll<GrCommentThread>(
-            'gr-comment-thread'
-          );
-
-        assert.equal(threads.length, 1);
-        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
-        assert.equal(threads[0].thread?.path, element.file.path);
+        assert.equal(addDraftSpy.callCount, 1);
+        const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+        assert.equal(draft1.side, CommentSide.PARENT);
+        assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
+        assert.equal(draft1.path, element.file.path);
       }
     );
 
@@ -1346,13 +1272,7 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-
-      assert.equal(threads.length, 0);
+      assert.isFalse(addDraftSpy.called);
       assert.isTrue(alertSpy.called);
     });
 
@@ -1374,12 +1294,7 @@
         })
       );
 
-      assertIsDefined(element.diffElement);
-      const threads =
-        element.diffElement.querySelectorAll<GrCommentThread>(
-          'gr-comment-thread'
-        );
-      assert.equal(threads.length, 0);
+      assert.isFalse(addDraftSpy.called);
       assert.isTrue(alertSpy.called);
     });
   });
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 4687fc1..0f54ad1 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
@@ -46,7 +46,6 @@
   PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
-  ServerInfo,
   CommentMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
@@ -59,7 +58,7 @@
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
-import {fireAlert, fire, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fire} from '../../../utils/event-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 import {toggleClass, whenVisible} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
@@ -80,7 +79,6 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {configModelToken} from '../../../models/config/config-model';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined.js';
@@ -115,12 +113,6 @@
 @customElement('gr-diff-view')
 export class GrDiffView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired when user tries to navigate away while comments are pending save.
    *
    * @event show-alert
@@ -187,21 +179,7 @@
   @state()
   files: Files = {sortedPaths: [], changeFilesByPath: {}};
 
-  // Private but used in tests
-  // Use path getter/setter.
-  _path?: string;
-
-  get path() {
-    return this._path;
-  }
-
-  set path(path: string | undefined) {
-    if (this._path === path) return;
-    const oldPath = this._path;
-    this._path = path;
-    this.pathChanged();
-    this.requestUpdate('path', oldPath);
-  }
+  @state() path?: string;
 
   /** Allows us to react when the user switches to the DIFF view. */
   // Private but used in tests.
@@ -214,9 +192,6 @@
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
 
-  @state()
-  private serverConfig?: ServerInfo;
-
   // Private but used in tests.
   @state()
   userPrefs?: PreferencesInfo;
@@ -261,8 +236,6 @@
 
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
-
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
@@ -360,13 +333,6 @@
     );
     subscribe(
       this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-    subscribe(
-      this,
       () => this.getCommentsModel().changeComments$,
       changeComments => {
         this.changeComments = changeComments;
@@ -986,12 +952,8 @@
   }
 
   private renderDialogs() {
-    return html` <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      >
-      </gr-apply-fix-dialog>
+    return html`
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <gr-diff-preferences-dialog
         id="diffPreferencesDialog"
         @reload-diff-preference=${this.handleReloadingDiffPreference}
@@ -1000,12 +962,10 @@
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .patchNum=${this.patchNum}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
-      </dialog>`;
+      </dialog>
+    `;
   }
 
   /**
@@ -1404,12 +1364,6 @@
     };
   }
 
-  private pathChanged() {
-    if (this.path) {
-      fireTitleChange(this, computeTruncatedPath(this.path));
-    }
-  }
-
   // Private but used in tests
   formatFilesForDropdown(): DropdownItem[] {
     if (!this.files) return [];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index ed7ce1b..737e964 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -42,7 +42,6 @@
   PARENT,
   PatchSetNum,
   PatchSetNumber,
-  PathToCommentsInfoMap,
   RepoName,
   RevisionPatchSetNum,
   UrlEncodedCommentId,
@@ -487,7 +486,7 @@
       assertIsDefined(element.cursor);
       sinon.stub(element.cursor, 'isAtEnd').returns(true);
       element.changeNum = 42 as NumericChangeId;
-      const comment: PathToCommentsInfoMap = {
+      const comment: {[path: string]: CommentInfo[]} = {
         'wheatley.md': [createComment('c2', 21, 10, 'wheatley.md')],
       };
       element.changeComments = new ChangeComments(comment);
@@ -1169,7 +1168,7 @@
     });
 
     test(
-      '_prefs.manual_review true means set reviewed is not ' +
+      'prefs.manual_review true means set reviewed is not ' +
         'automatically called',
       async () => {
         const setReviewedFileStatusStub = sinon
@@ -1205,7 +1204,7 @@
       }
     );
 
-    test('_prefs.manual_review false means set reviewed is called', async () => {
+    test('prefs.manual_review false means set reviewed is called', async () => {
       const setReviewedFileStatusStub = sinon
         .stub(changeModel, 'setReviewedFilesStatus')
         .callsFake(() => Promise.resolve());
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index e1dfd1f..76d67af 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -14,7 +14,6 @@
   getParentIndex,
   getRevisionByPatchNum,
   isMergeParent,
-  sortRevisions,
   PatchSet,
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
@@ -48,7 +47,6 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {changeViewModelToken} from '../../../models/views/change';
 import {fireNoBubbleNoCompose} from '../../../utils/event-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -127,8 +125,6 @@
 
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     subscribe(
@@ -159,7 +155,7 @@
     subscribe(
       this,
       () => this.getChangeModel().revisions$,
-      x => (this.sortedRevisions = sortRevisions(Object.values(x || {})))
+      x => (this.sortedRevisions = x)
     );
     subscribe(
       this,
@@ -327,16 +323,7 @@
   }
 
   private computeText(patchNum: PatchSetNum, prefix: string, sha: string) {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.COMMENTS_CHIPS_IN_FILE_LIST)
-    ) {
-      return `${prefix}${patchNum} | ${sha}`;
-    }
-    return (
-      `${prefix}${patchNum}` +
-      `${this.computePatchSetCommentsString(patchNum)}` +
-      ` | ${sha}`
-    );
+    return `${prefix}${patchNum} | ${sha}`;
   }
 
   private createDropdownEntry(
@@ -350,17 +337,13 @@
       mobileText: this.computeMobileText(patchNum),
       bottomText: `${this.computePatchSetDescription(patchNum)}`,
       value: patchNum,
-    };
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.COMMENTS_CHIPS_IN_FILE_LIST)
-    ) {
-      entry.commentThreads = this.changeComments?.computeCommentThreads(
+      commentThreads: this.changeComments?.computeCommentThreads(
         {
           patchNum,
         },
         true
-      );
-    }
+      ),
+    };
     const date = this.computePatchSetDate(patchNum);
     if (date) {
       entry.date = date;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index 20d2549..b4ab043 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -20,7 +20,7 @@
   RevisionInfo,
   Timestamp,
   UrlEncodedCommentId,
-  PathToCommentsInfoMap,
+  CommentInfo,
 } from '../../../types/common';
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {SpecialFilePath} from '../../../constants/constants';
@@ -147,6 +147,7 @@
         mobileText: EDIT,
         bottomText: '',
         value: EDIT,
+        commentThreads: [],
       },
       {
         disabled: true,
@@ -156,6 +157,7 @@
         bottomText: '',
         value: 3,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: true,
@@ -165,6 +167,7 @@
         bottomText: '',
         value: 2,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: true,
@@ -174,6 +177,7 @@
         bottomText: '',
         value: 1,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         text: 'Base',
@@ -287,6 +291,7 @@
         mobileText: EDIT,
         bottomText: '',
         value: EDIT,
+        commentThreads: [],
       },
       {
         disabled: false,
@@ -296,6 +301,7 @@
         bottomText: '',
         value: 3,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: false,
@@ -305,6 +311,7 @@
         bottomText: 'description',
         value: 2,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
       {
         disabled: true,
@@ -314,6 +321,7 @@
         bottomText: '',
         value: 1,
         date: '2020-02-01 01:02:03.000000000' as Timestamp,
+        commentThreads: [],
       } as DropdownItem,
     ];
 
@@ -331,7 +339,7 @@
 
   test('computePatchSetCommentsString', () => {
     // Test string with unresolved comments.
-    const comments: PathToCommentsInfoMap = {
+    const comments: {[path: string]: CommentInfo[]} = {
       foo: [
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
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 6dbdca6..a8bc84c 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
@@ -45,7 +45,7 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    fireTitleChange(this, 'Documentation Search');
+    fireTitleChange('Documentation Search');
   }
 
   static override get styles() {
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 0fc754c..5786112 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
@@ -18,7 +18,7 @@
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
-import {fireAlert, fireReload} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
 import {
   assertIsDefined,
   query as queryUtil,
@@ -34,6 +34,7 @@
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {whenVisible} from '../../../utils/dom-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {changeModelToken} from '../../../models/change/change-model';
 
 @customElement('gr-edit-controls')
 export class GrEditControls extends LitElement {
@@ -77,6 +78,8 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   static override get styles() {
@@ -451,7 +454,7 @@
           return;
         }
         this.closeDialog(this.openDialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   }
 
@@ -471,7 +474,7 @@
           return;
         }
         this.closeDialog(dialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   };
 
@@ -489,7 +492,7 @@
           return;
         }
         this.closeDialog(dialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   };
 
@@ -507,7 +510,7 @@
           return;
         }
         this.closeDialog(dialog);
-        fireReload(this, true);
+        this.getChangeModel().navigateToChangeResetReload();
       });
   };
 
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 0729f21..0e6778a 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
@@ -26,8 +26,12 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import {waitForEventOnce} from '../../../utils/event-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {
+  ChangeModel,
+  changeModelToken,
+} from '../../../models/change/change-model';
+import {SinonStubbedMember} from 'sinon';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -36,6 +40,9 @@
   let closeDialogSpy: sinon.SinonSpy;
   let hideDialogStub: sinon.SinonStub;
   let queryStub: sinon.SinonStub;
+  let navigateResetStub: SinonStubbedMember<
+    ChangeModel['navigateToChangeResetReload']
+  >;
 
   setup(async () => {
     element = await fixture<GrEditControls>(html`
@@ -47,6 +54,10 @@
     closeDialogSpy = sinon.spy(element, 'closeDialog');
     hideDialogStub = sinon.stub(element, 'hideAllDialogs');
     queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
+    navigateResetStub = sinon.stub(
+      testResolver(changeModelToken),
+      'navigateToChangeResetReload'
+    );
     await element.updateComplete;
   });
 
@@ -298,7 +309,7 @@
       assert.isTrue(deleteStub.called);
       await deleteStub.lastCall.returnValue;
       assert.equal(element.path, '');
-      assert.equal(eventStub.firstCall.args[0].type, 'reload');
+      assert.equal(navigateResetStub.callCount, 1);
       assert.isTrue(closeDialogSpy.called);
     });
 
@@ -397,7 +408,7 @@
 
       await renameStub.lastCall.returnValue;
       assert.equal(element.path, '');
-      assert.equal(eventStub.firstCall.args[0].type, 'reload');
+      assert.equal(navigateResetStub.callCount, 1);
       assert.isTrue(closeDialogSpy.called);
     });
 
@@ -485,7 +496,7 @@
       assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
       return restoreStub.lastCall.returnValue.then(() => {
         assert.equal(element.path, '');
-        assert.equal(eventStub.firstCall.args[0].type, 'reload');
+        assert.equal(navigateResetStub.callCount, 1);
         assert.isTrue(closeDialogSpy.called);
       });
     });
@@ -553,7 +564,8 @@
       assert.equal(fileStub.lastCall.args[0], 1);
       assert.equal(fileStub.lastCall.args[1], 'test.php');
       assert.equal(fileStub.lastCall.args[2], 'base64');
-      await waitForEventOnce(element, 'reload');
+      await waitUntil(() => navigateResetStub.called);
+      assert.equal(navigateResetStub.callCount, 1);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 878c5e9..f37a3d9 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -9,7 +9,6 @@
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../gr-default-editor/gr-default-editor';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {
   EditPreferencesInfo,
   Base64FileContent,
@@ -17,11 +16,7 @@
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
-import {
-  fireAlert,
-  fireTitleChange,
-  fireReload,
-} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {assertIsDefined} from '../../../utils/common-util';
@@ -57,12 +52,6 @@
 @customElement('gr-editor-view')
 export class GrEditorView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired to notify the user of
    *
    * @event show-alert
@@ -333,17 +322,6 @@
   viewStateChanged() {
     if (this.viewState?.childView !== ChangeChildView.EDIT) return;
 
-    // NOTE: This may be called before attachment (e.g. while parentElement is
-    // null). Fire title-change in an async so that, if attachment to the DOM
-    // has been queued, the event can bubble up to the handler in gr-app.
-    setTimeout(() => {
-      if (!this.viewState) return;
-      const title = `Editing ${computeTruncatedPath(
-        this.viewState.editView?.path
-      )}`;
-      fireTitleChange(this, title);
-    });
-
     const promises = [];
     promises.push(this.getFileData());
     return Promise.all(promises);
@@ -512,13 +490,7 @@
         )
         .then(() => {
           assertIsDefined(this.change, 'change');
-          // TODO: `forceReload: true` does not seem to work as expected: The patchset is not
-          // updated. Thus we are also calling `fireReload()` here. That can probably be
-          // cleaned up once the change-view was migrated to fully relying on the change model.
-          fireReload(this);
-          this.getNavigation().setUrl(
-            createChangeUrl({change: this.change, forceReload: true})
-          );
+          this.getChangeModel().navigateToChangeResetReload();
         });
     });
   };
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 98f1f25..75bfca2 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -118,7 +118,7 @@
 
   @state() private version?: string;
 
-  @state() private view?: GerritView;
+  @state() view?: GerritView;
 
   // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
   @state() private childView?: ChangeChildView;
@@ -177,7 +177,7 @@
     document.addEventListener('page-error', e => {
       this.handlePageError(e);
     });
-    this.addEventListener('title-change', e => {
+    document.addEventListener('title-change', e => {
       this.handleTitleChange(e);
     });
     this.addEventListener('dialog-change', e => {
@@ -549,15 +549,21 @@
 
   private renderPluginScreen() {
     if (this.view !== GerritView.PLUGIN_SCREEN) return nothing;
+    if (!this.params) return nothing;
     const pluginViewState = this.params as PluginViewState;
-    return html`
-      <gr-endpoint-decorator .name=${this.computePluginScreenName()}>
-        <gr-endpoint-param
-          name="token"
-          .value=${pluginViewState.screen}
-        ></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    `;
+    const pluginScreenName = this.computePluginScreenName();
+
+    return keyed(
+      pluginScreenName,
+      html`
+        <gr-endpoint-decorator .name=${pluginScreenName}>
+          <gr-endpoint-param
+            name="token"
+            .value=${pluginViewState.screen}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
   }
 
   private renderCLAView() {
diff --git a/polygerrit-ui/app/elements/gr-app-element_test.ts b/polygerrit-ui/app/elements/gr-app-element_test.ts
new file mode 100644
index 0000000..ec415ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-app';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrAppElement} from './gr-app-element';
+import {queryAndAssert} from '../utils/common-util';
+import {GerritView} from '../services/router/router-model';
+import {PluginViewState} from '../models/views/plugin';
+import {GrEndpointDecorator} from './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+
+suite('gr-app-element tests', () => {
+  let element: GrAppElement;
+
+  setup(async () => {
+    element = await fixture<GrAppElement>(
+      html`<gr-app-element></gr-app-element>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-css-mixins> </gr-css-mixins>
+        <gr-endpoint-decorator name="banner"> </gr-endpoint-decorator>
+        <gr-main-header loggedin id="mainHeader" role="banner">
+        </gr-main-header>
+        <main>
+          <div class="errorView" id="errorView">
+            <div class="errorEmoji"></div>
+            <div class="errorText"></div>
+            <div class="errorMoreInfo"></div>
+          </div>
+        </main>
+        <footer>
+          <div>
+            Powered by
+            <a
+              href="https://www.gerritcodereview.com/"
+              rel="noopener"
+              target="_blank"
+            >
+              Gerrit Code Review
+            </a>
+            ()
+            <gr-endpoint-decorator name="footer-left"> </gr-endpoint-decorator>
+          </div>
+          <div>
+            Press “?” for keyboard shortcuts
+            <gr-endpoint-decorator name="footer-right"> </gr-endpoint-decorator>
+          </div>
+        </footer>
+        <gr-notifications-prompt> </gr-notifications-prompt>
+        <gr-endpoint-decorator name="plugin-overlay"> </gr-endpoint-decorator>
+        <gr-error-manager id="errorManager"> </gr-error-manager>
+        <gr-plugin-host id="plugins"> </gr-plugin-host>
+      `
+    );
+  });
+
+  test('renders plugin screen, changes endpoint instance', async () => {
+    element.view = GerritView.PLUGIN_SCREEN;
+    element.params = {
+      view: GerritView.PLUGIN_SCREEN,
+      screen: 'test-screen-1',
+      plugin: 'test-plugin',
+    } as PluginViewState;
+    await element.updateComplete;
+
+    const main1 = queryAndAssert(element, 'main');
+    const endpoint1 = queryAndAssert<GrEndpointDecorator>(
+      main1,
+      'gr-endpoint-decorator'
+    );
+    assert.equal(endpoint1.name, 'test-plugin-screen-test-screen-1');
+
+    element.params = {
+      view: GerritView.PLUGIN_SCREEN,
+      screen: 'test-screen-2',
+      plugin: 'test-plugin',
+    } as PluginViewState;
+    await element.updateComplete;
+
+    const main2 = queryAndAssert(element, 'main');
+    const endpoint2 = queryAndAssert<GrEndpointDecorator>(
+      main2,
+      'gr-endpoint-decorator'
+    );
+    assert.equal(endpoint2.name, 'test-plugin-screen-test-screen-2');
+
+    // Plugin screen endpoints have a variable name. Lit must not re-use the
+    // same element instance. (Issue 16884)
+    assert.isFalse(endpoint1 === endpoint2);
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index d6a14ed..6589ee8 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -21,6 +21,7 @@
   initErrorReporter,
   initWebVitals,
   initClickReporter,
+  initInteractionReporter,
 } from '../services/gr-reporting/gr-reporting_impl';
 import {Finalizable} from '../services/registry';
 
@@ -36,6 +37,7 @@
     initWebVitals(reportingService);
     initErrorReporter(reportingService);
     initClickReporter(reportingService);
+    initInteractionReporter(reportingService);
   }
   window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
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 2f835f7..a2b61e0 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
@@ -53,7 +53,7 @@
     super.connectedCallback();
     this.loadData();
 
-    fireTitleChange(this, 'New Contributor Agreement');
+    fireTitleChange('New Contributor Agreement');
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 2695ea7..24a18c9 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -81,12 +81,6 @@
 @customElement('gr-settings-view')
 export class GrSettingsView extends LitElement {
   /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
    * Fired with email confirmation text, or when the page reloads.
    *
    * @event show-alert
@@ -261,7 +255,7 @@
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
     document.addEventListener('location-change', this.handleLocationChange);
-    fireTitleChange(this, 'Settings');
+    fireTitleChange('Settings');
   }
 
   override firstUpdated() {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index a0d7fab..0bcb09c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -544,10 +544,8 @@
 
   test('calls the title-change event', async () => {
     const titleChangedStub = sinon.stub();
-
-    // Create a new view.
     const newElement = document.createElement('gr-settings-view');
-    newElement.addEventListener('title-change', titleChangedStub);
+    document.addEventListener('title-change', titleChangedStub);
 
     const div = await fixture(html`<div></div>`);
     div.appendChild(newElement);
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 49a3b06..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
@@ -20,10 +20,10 @@
 import {
   computeDiffFromContext,
   getLastComment,
-  createUnsavedComment,
   getFirstComment,
-  createUnsavedReply,
+  createNewReply,
   NEWLINE_PATTERN,
+  id,
 } from '../../../utils/comment-util';
 import {ChangeMessageId} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
@@ -38,12 +38,9 @@
   CommentRange,
   CommentThread,
   isDraft,
-  isDraftOrUnsaved,
   isRobot,
-  isUnsaved,
   NumericChangeId,
   RepoName,
-  UnsavedInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {CommentEditingChangedDetail, GrComment} from '../gr-comment/gr-comment';
@@ -51,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';
@@ -128,13 +129,14 @@
   thread?: CommentThread;
 
   /**
-   * Id of the first comment and thus must not change. Will be derived from
+   * Id of the first comment, must not change. Will be derived from
    * the `thread` property in the first willUpdate() cycle.
    *
    * The `rootId` property is also used in gr-diff for maintaining lists and
    * maps of threads and their associated elements.
    *
-   * Only stays `undefined` for new threads that only have an unsaved comment.
+   * For newly created threads in this session the `client_id` property  of the
+   * first comment will be used instead of the `id` property.
    */
   @property({type: String})
   rootId?: UrlEncodedCommentId;
@@ -190,15 +192,6 @@
   @property({type: Boolean, attribute: 'false'})
   editing = false;
 
-  /**
-   * This can either be an unsaved reply to the last comment or the unsaved
-   * content of a brand new comment thread (then `comments` is empty).
-   * If set, then `thread.comments` must not contain a draft. A thread can only
-   * contain *either* an unsaved comment *or* a draft, not both.
-   */
-  @state()
-  unsavedComment?: UnsavedInfo;
-
   @state()
   changeNum?: NumericChangeId;
 
@@ -477,22 +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.
-    const draftComment = this.renderComment(this.getDraftOrUnsaved());
+    // 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.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);
@@ -503,25 +500,23 @@
         ?initially-collapsed=${initiallyCollapsed}
         ?robot-button-disabled=${robotButtonDisabled}
         ?show-patchset=${this.showPatchset}
-        ?show-ported-comment=${this.showPortedComment &&
-        comment.id === this.rootId}
+        ?show-ported-comment=${this.showPortedComment && isFirstComment}
         @reply-to-comment=${this.handleReplyToComment}
         @copy-comment-link=${this.handleCopyLink}
         @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">${
@@ -613,9 +608,6 @@
     if (this.firstWillUpdateDone) return;
     this.firstWillUpdateDone = true;
 
-    if (this.getFirstComment() === undefined) {
-      this.unsavedComment = createUnsavedComment(this.thread);
-    }
     this.unresolved = this.getLastComment()?.unresolved ?? true;
     this.diff = this.computeDiff();
     this.highlightRange = this.computeHighlightRange();
@@ -624,28 +616,18 @@
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
     if (changed.has('thread')) {
-      if (!this.isDraftOrUnsaved()) {
+      assertIsDefined(this.thread, 'thread');
+      assertIsDefined(this.getFirstComment(), 'first comment');
+      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.rootId = this.getFirstComment()?.id;
-      if (this.isDraft()) {
-        this.unsavedComment = undefined;
-      }
+      this.hasDraft = this.isDraft();
+      this.rootId = id(this.getFirstComment()!);
     }
     if (changed.has('editing')) {
-      // changed.get('editing') contains the old value. We only want to trigger
-      // when changing from editing to non-editing (user has cancelled/saved).
-      // We do *not* want to trigger on first render (old value is `null`)
-      if (!this.editing && changed.get('editing') === true) {
-        this.unsavedComment = undefined;
-        if (this.thread?.comments.length === 0) {
-          this.remove();
-        }
-      }
       fire(this, 'comment-thread-editing-changed', {value: this.editing});
     }
   }
@@ -670,24 +652,11 @@
     return isDraft(this.getLastComment());
   }
 
-  private isDraftOrUnsaved(): boolean {
-    return this.isDraft() || this.isUnsaved();
-  }
-
-  private getDraftOrUnsaved(): Comment | undefined {
-    if (this.unsavedComment) return this.unsavedComment;
+  private getDraft(): Comment | undefined {
     if (this.isDraft()) return this.getLastComment();
     return undefined;
   }
 
-  private isNewThread(): boolean {
-    return this.thread?.comments.length === 0;
-  }
-
-  private isUnsaved(): boolean {
-    return !!this.unsavedComment || this.thread?.comments.length === 0;
-  }
-
   private isPatchsetLevel() {
     return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
@@ -717,7 +686,6 @@
     if (!this.changeNum || !this.repoName || !this.thread?.path) {
       return undefined;
     }
-    if (this.isNewThread()) return undefined;
     return createDiffUrl({
       changeNum: this.changeNum,
       repo: this.repoName,
@@ -743,14 +711,12 @@
 
   // Does not work for patchset level comments
   private getUrlForFileComment() {
-    if (!this.repoName || !this.changeNum || this.isNewThread()) {
-      return undefined;
-    }
-    assertIsDefined(this.rootId, 'rootId of comment thread');
+    const id = this.getFirstComment()?.id;
+    if (!id || !this.repoName || !this.changeNum) return undefined;
     return createDiffUrl({
       changeNum: this.changeNum,
       repo: this.repoName,
-      commentId: this.rootId,
+      commentId: id,
     });
   }
 
@@ -827,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.unsavedComment = unsaved;
+      this.getCommentsModel().addNewDraft(newReply);
     } else {
       try {
         this.saving = true;
-        await this.getCommentsModel().saveDraft(unsaved);
+        await this.getCommentsModel().saveDraft(newReply);
       } finally {
         this.saving = false;
       }
@@ -875,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 104bb1e..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,6 +14,7 @@
   CommentInfo,
   RepoName,
   DraftInfo,
+  SavingState,
 } from '../../../types/common';
 import {
   mockPromise,
@@ -25,9 +26,10 @@
 import {
   createAccountDetailWithId,
   createThread,
+  createNewDraft,
 } from '../../../test/test-data-generators';
 import {SinonStubbedMember} from 'sinon';
-import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
 import {SpecialFilePath} from '../../../constants/constants';
 import {GrIcon} from '../gr-icon/gr-icon';
@@ -37,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',
@@ -52,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,
-  __draft: true,
+  savingState: SavingState.OK,
 };
 
 const commentWithContext = {
@@ -126,23 +128,23 @@
   });
 
   test('renders unsaved', async () => {
-    element.thread = createThread();
+    element.thread = createThread(createNewDraft());
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
       /* HTML */ `
         <div class="fileName">
-          <span>test-path-comment-thread</span>
+          <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
+            test-path-comment-thread
+          </a>
           <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
         </div>
         <div class="pathInfo">
           <span>#314</span>
         </div>
         <div id="container">
-          <h3 class="assistive-tech-only">
-            Unresolved Draft Comment thread by Yoda
-          </h3>
-          <div class="comment-box unresolved" tabindex="0">
+          <h3 class="assistive-tech-only">Draft Comment thread by Yoda</h3>
+          <div class="comment-box" tabindex="0">
             <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
           </div>
         </div>
@@ -313,13 +315,15 @@
 
   suite('action button clicks', () => {
     let savePromise: MockPromise<DraftInfo>;
-    let stub: SinonStubbedMember<CommentsModel['saveDraft']>;
+    let stubSave: SinonStubbedMember<CommentsModel['saveDraft']>;
+    let stubAdd: SinonStubbedMember<CommentsModel['addNewDraft']>;
 
     setup(async () => {
       savePromise = mockPromise<DraftInfo>();
-      stub = sinon
+      stubSave = sinon
         .stub(testResolver(commentsModelToken), 'saveDraft')
         .returns(savePromise);
+      stubAdd = sinon.stub(testResolver(commentsModelToken), 'addNewDraft');
 
       element.thread = createThread(c1, {...c2, unresolved: true});
       await element.updateComplete;
@@ -327,9 +331,9 @@
 
     test('handle Acknowledge', async () => {
       queryAndAssert<GrButton>(element, '#ackBtn').click();
-      waitUntilCalled(stub, 'saveDraft()');
-      assert.equal(stub.lastCall.firstArg.message, 'Acknowledged');
-      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      waitUntilCalled(stubSave, 'saveDraft()');
+      assert.equal(stubSave.lastCall.firstArg.message, 'Acknowledged');
+      assert.equal(stubSave.lastCall.firstArg.unresolved, false);
       assert.isTrue(element.saving);
 
       savePromise.resolve();
@@ -339,47 +343,23 @@
 
     test('handle Done', async () => {
       queryAndAssert<GrButton>(element, '#doneBtn').click();
-      waitUntilCalled(stub, 'saveDraft()');
-      assert.equal(stub.lastCall.firstArg.message, 'Done');
-      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      waitUntilCalled(stubSave, 'saveDraft()');
+      assert.equal(stubSave.lastCall.firstArg.message, 'Done');
+      assert.equal(stubSave.lastCall.firstArg.unresolved, false);
     });
 
     test('handle Reply', async () => {
-      assert.isUndefined(element.unsavedComment);
+      assert.equal(element.thread?.comments.length, 2);
       queryAndAssert<GrButton>(element, '#replyBtn').click();
-      assert.equal(element.unsavedComment?.message, '');
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.message, '');
     });
 
     test('handle Quote', async () => {
-      assert.isUndefined(element.unsavedComment);
+      assert.equal(element.thread?.comments.length, 2);
       queryAndAssert<GrButton>(element, '#quoteBtn').click();
-      assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
-    });
-  });
-
-  suite('self removal when empty thread changed to editing:false', () => {
-    let threadEl: GrCommentThread;
-
-    setup(async () => {
-      threadEl = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
-      threadEl.thread = createThread();
-    });
-
-    test('new thread el normally has a parent and an unsaved comment', async () => {
-      await waitUntil(() => threadEl.editing);
-      assert.isOk(threadEl.unsavedComment);
-      assert.isOk(threadEl.parentElement);
-    });
-
-    test('thread el removed after clicking CANCEL', async () => {
-      await waitUntil(() => threadEl.editing);
-
-      const commentEl = queryAndAssert(threadEl, 'gr-comment');
-      const buttonEl = queryAndAssert<GrButton>(commentEl, 'gr-button.cancel');
-      buttonEl.click();
-
-      await waitUntil(() => !threadEl.editing);
-      assert.isNotOk(threadEl.parentElement);
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.message.trim(), `> ${c2.message}`);
     });
   });
 
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 de6a39c9..27a5590 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -26,9 +26,11 @@
   RepoName,
   RobotCommentInfo,
   Comment,
-  isDraftOrUnsaved,
   isRobot,
-  isUnsaved,
+  isSaving,
+  isError,
+  isDraft,
+  isNew,
 } from '../../../types/common';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
@@ -36,6 +38,7 @@
   getContentInCommentRange,
   getUserSuggestion,
   hasUserSuggestion,
+  id,
   NEWLINE_PATTERN,
   USER_SUGGESTION_START_PATTERN,
 } from '../../../utils/comment-util';
@@ -63,15 +66,11 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 
-const UNSAVED_MESSAGE = 'Unable to save draft';
-
 const FILE = 'FILE';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
 
-export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
-
 declare global {
   interface HTMLElementEventMap {
     'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
@@ -166,21 +165,11 @@
   @property({type: String})
   messagePlaceholder?: string;
 
-  /* private, but used in css rules */
-  @property({type: Boolean, reflect: true})
-  saving = false;
-
   // GrReplyDialog requires the patchset level comment to always remain
   // editable.
   @property({type: Boolean, attribute: 'permanent-editing-mode'})
   permanentEditingMode = false;
 
-  /**
-   * `saving` and `autoSaving` are separate and cannot be set at the same time.
-   * `saving` affects the UI state (disabled buttons, etc.) and eventually
-   * leaves editing mode, but `autoSaving` just happens in the background
-   * without the user noticing.
-   */
   @state()
   autoSaving?: Promise<DraftInfo>;
 
@@ -201,9 +190,6 @@
   @state()
   unresolved = true;
 
-  @property({type: Boolean})
-  unableToSave = false;
-
   @property({type: Boolean, attribute: 'show-patchset'})
   showPatchset = false;
 
@@ -333,13 +319,9 @@
         :host([collapsed]) {
           padding: var(--spacing-s) var(--spacing-m);
         }
-        :host([saving]) {
-          pointer-events: none;
-        }
-        :host([saving]) .actions,
-        :host([saving]) .robotActions,
-        :host([saving]) .date {
-          opacity: 0.5;
+        :host([error]) {
+          background-color: var(--error-background);
+          border-radius: var(--border-radius);
         }
         .header {
           align-items: center;
@@ -508,8 +490,13 @@
   }
 
   override render() {
-    if (isUnsaved(this.comment) && !this.editing) return;
-    const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+    if (!this.comment) return;
+    this.toggleAttribute('saving', isSaving(this.comment));
+    this.toggleAttribute('error', isError(this.comment));
+    const classes = {
+      container: true,
+      draft: isDraft(this.comment),
+    };
     return html`
       <gr-endpoint-decorator name="comment">
         <gr-endpoint-param name="comment" .value=${this.comment}>
@@ -518,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()}
@@ -552,23 +536,19 @@
         <div class="headerMiddle">${this.renderCollapsedContent()}</div>
         ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
         ${this.renderDeleteButton()} ${this.renderPatchset()}
-        ${this.renderDate()} ${this.renderToggle()}
+        ${this.renderSeparator()} ${this.renderDate()} ${this.renderToggle()}
       </div>
     `;
   }
 
   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>
     `;
   }
@@ -586,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.unableToSave) {
+    if (isError(this.comment)) {
       label += ' (Failed to save)';
       tooltip = 'Unable to save draft. Please try to save again.';
     }
@@ -635,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
@@ -666,19 +641,36 @@
     `;
   }
 
+  private renderSeparator() {
+    // This should match the condition of `renderPatchset()`.
+    if (!this.showPatchset) return;
+    // This should match the condition of `renderDate()`.
+    if (this.collapsed) return;
+    // Render separator, if both are present: patchset AND date.
+    return html`<span class="separator"></span>`;
+  }
+
   private renderDate() {
-    if (!this.comment?.updated || this.collapsed) return;
+    if (this.collapsed) return;
     return html`
-      <span class="separator"></span>
       <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
-        <gr-date-formatter
-          withTooltip
-          .dateStr=${this.comment.updated}
-        ></gr-date-formatter>
+        ${this.renderDateInner()}
       </span>
     `;
   }
 
+  private renderDateInner() {
+    if (isError(this.comment)) return 'Error';
+    if (isSaving(this.comment) && !this.autoSaving) 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';
@@ -710,7 +702,6 @@
         class="editMessage"
         autocomplete="on"
         code=""
-        ?disabled=${this.saving}
         rows="4"
         .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
@@ -742,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"
@@ -757,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">
@@ -777,10 +768,9 @@
   }
 
   private renderDraftActions() {
-    if (!isDraftOrUnsaved(this.comment)) return;
+    if (!isDraft(this.comment)) return;
     return html`
       <div class="rightActions">
-        ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
         ${this.renderDiscardButton()} ${this.renderEditButton()}
         ${this.renderCancelButton()} ${this.renderSaveButton()}
         ${this.renderCopyLinkIcon()}
@@ -819,7 +809,7 @@
     if (this.editing || this.permanentEditingMode) return;
     return html`<gr-button
       link
-      ?disabled=${this.saving}
+      ?disabled=${isSaving(this.comment) && !this.autoSaving}
       class="action discard"
       @click=${this.discard}
       >Discard</gr-button
@@ -828,11 +818,7 @@
 
   private renderEditButton() {
     if (this.editing) return;
-    return html`<gr-button
-      link
-      ?disabled=${this.saving}
-      class="action edit"
-      @click=${this.edit}
+    return html`<gr-button link class="action edit" @click=${this.edit}
       >Edit</gr-button
     >`;
   }
@@ -842,7 +828,7 @@
     return html`
       <gr-button
         link
-        ?disabled=${this.saving}
+        ?disabled=${isSaving(this.comment) && !this.autoSaving}
         class="action cancel"
         @click=${this.cancel}
         >Cancel</gr-button
@@ -851,7 +837,7 @@
   }
 
   private renderSaveButton() {
-    if (!this.editing && !this.unableToSave) return;
+    if (!this.editing) return;
     return html`
       <gr-button
         link
@@ -887,7 +873,6 @@
         link
         secondary
         class="action show-fix"
-        ?disabled=${this.saving}
         @click=${() => this.handleShowFix()}
       >
         Show Fix
@@ -923,13 +908,11 @@
   }
 
   private getUrlForComment() {
-    const comment = this.comment;
-    if (!comment || !this.changeNum || !this.repoName) return '';
-    if (!comment.id) throw new Error('comment must have an id');
+    if (!this.changeNum || !this.repoName || !this.comment?.id) return '';
     return createDiffUrl({
       changeNum: this.changeNum,
       repo: this.repoName,
-      commentId: comment.id,
+      commentId: this.comment.id,
     });
   }
 
@@ -937,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;
@@ -959,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();
     }
@@ -988,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;
   }
@@ -1051,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
@@ -1066,7 +1062,7 @@
   // private, but visible for testing
   isSaveDisabled() {
     assertIsDefined(this.comment, 'comment');
-    if (this.saving) return true;
+    if (isSaving(this.comment) && !this.autoSaving) return true;
     return !this.messageText?.trimEnd();
   }
 
@@ -1153,24 +1149,22 @@
   // 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.saving || 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;
 
     try {
-      this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+      this.autoSaving = this.rawSave({showToast: false});
       await this.autoSaving;
     } finally {
       this.autoSaving = undefined;
@@ -1183,52 +1177,51 @@
   }
 
   async save() {
-    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
-    // If it's an unsaved comment then it does not have a draftID yet which
-    // means sending another save() request will create a new draft
-    if (isUnsaved(this.comment) && this.saving) return;
+    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 (isSaving(this.comment) && !this.autoSaving) return;
 
-    try {
-      this.saving = true;
-      this.unableToSave = false;
-      if (this.autoSaving) {
-        this.comment = await this.autoSaving;
+    if (!this.permanentEditingMode) {
+      this.editing = false;
+    }
+    if (this.autoSaving) {
+      this.comment = await this.autoSaving;
+    }
+    // Depending on whether `messageToSave` is empty we treat this either as
+    // a discard or a save action.
+    const messageToSave = this.messageText.trimEnd();
+    if (messageToSave === '') {
+      if (!this.permanentEditingMode || this.somethingToSave()) {
+        await this.getCommentsModel().discardDraft(id(this.comment));
       }
-      // Depending on whether `messageToSave` is empty we treat this either as
-      // a discard or a save action.
-      const messageToSave = this.messageText.trimEnd();
-      if (messageToSave === '') {
-        // Don't try to discard UnsavedInfo. Nothing to do then.
-        if (this.comment.id) {
-          await this.getCommentsModel().discardDraft(this.comment.id);
-        }
-      } else {
-        // No need to make a backend call when nothing has changed.
-        if (
-          messageToSave !== this.comment?.message ||
-          this.unresolved !== this.comment.unresolved
-        ) {
-          await this.rawSave(messageToSave, {showToast: true});
-        }
+    } else {
+      // 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;
       }
-      if (!this.permanentEditingMode) {
-        this.editing = false;
-      }
-    } catch (e) {
-      this.unableToSave = true;
-      throw e;
-    } finally {
-      this.saving = false;
     }
   }
 
+  private somethingToSave() {
+    if (!this.comment) return false;
+    return (
+      isError(this.comment) ||
+      this.messageText.trimEnd() !== this.comment?.message ||
+      this.unresolved !== this.comment.unresolved
+    );
+  }
+
   /** For sharing between save() and autoSave(). */
-  private rawSave(message: string, options: {showToast: boolean}) {
-    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+  private rawSave(options: {showToast: boolean}) {
+    assert(isDraft(this.comment), 'only drafts are editable');
+    assert(!isSaving(this.comment), 'saving already in progress');
     return this.getCommentsModel().saveDraft(
       {
         ...this.comment,
-        message,
+        message: this.messageText.trimEnd(),
         unresolved: this.unresolved,
       },
       options.showToast
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 3094d37..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
@@ -17,10 +17,12 @@
   dispatch,
   MockPromise,
   stubFlags,
+  waitUntil,
 } from '../../../test/test-utils';
 import {
   AccountId,
   DraftInfo,
+  SavingState,
   EmailAddress,
   NumericChangeId,
   PatchSetNum,
@@ -31,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';
@@ -271,7 +273,7 @@
 
     test('renders draft', async () => {
       element.initiallyCollapsed = false;
-      (element.comment as DraftInfo).__draft = true;
+      (element.comment as DraftInfo).savingState = SavingState.OK;
       await element.updateComplete;
       assert.shadowDom.equal(
         element,
@@ -351,7 +353,7 @@
 
     test('renders draft in editing mode', async () => {
       element.initiallyCollapsed = false;
-      (element.comment as DraftInfo).__draft = true;
+      (element.comment as DraftInfo).savingState = SavingState.OK;
       element.editing = true;
       await element.updateComplete;
       assert.shadowDom.equal(
@@ -465,7 +467,7 @@
       },
       line: 5,
       path: 'test',
-      __draft: true,
+      savingState: SavingState.OK,
       message: 'hello world',
     };
     element.editing = true;
@@ -490,7 +492,7 @@
       },
       line: 5,
       path: 'test',
-      __draft: true,
+      savingState: SavingState.OK,
       message: 'hello world',
     };
     element.editing = true;
@@ -540,14 +542,13 @@
       element.changeNum = 42 as NumericChangeId;
       element.comment = {
         ...createComment(),
-        __draft: true,
+        savingState: SavingState.OK,
         path: '/path/to/file',
         line: 5,
       };
     });
 
     test('isSaveDisabled', async () => {
-      element.saving = false;
       element.unresolved = true;
       element.comment = {...createComment(), unresolved: true};
       element.messageText = 'asdf';
@@ -564,7 +565,7 @@
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
 
-      element.saving = true;
+      element.comment = {...element.comment, savingState: SavingState.SAVING};
       await element.updateComplete;
       assert.isTrue(element.isSaveDisabled());
     });
@@ -596,14 +597,12 @@
       waitUntilCalled(stub, 'saveDraft()');
       assert.equal(stub.lastCall.firstArg.message, textToSave);
       assert.equal(stub.lastCall.firstArg.unresolved, true);
-      assert.isTrue(element.editing);
-      assert.isTrue(element.saving);
+      assert.isFalse(element.editing);
 
       savePromise.resolve();
       await element.updateComplete;
 
       assert.isFalse(element.editing);
-      assert.isFalse(element.saving);
     });
 
     test('previewing formatting triggers save', async () => {
@@ -624,22 +623,30 @@
     });
 
     test('save failed', async () => {
-      sinon
-        .stub(commentsModel, 'saveDraft')
-        .returns(Promise.reject(new Error('saving failed')));
+      sinon.stub(commentsModel, 'saveDraft').returns(
+        Promise.resolve({
+          ...createNewDraft(),
+          message: 'something, not important',
+          unresolved: true,
+          savingState: SavingState.ERROR,
+        })
+      );
 
-      element.comment = createDraft();
+      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();
-      await element.updateComplete;
+      assert.isFalse(element.editing);
 
-      assert.isTrue(element.unableToSave);
+      await waitUntil(() => element.hasAttribute('error'));
       assert.isTrue(element.editing);
-      assert.isFalse(element.saving);
     });
 
     test('discard', async () => {
@@ -657,21 +664,19 @@
       await element.updateComplete;
       waitUntilCalled(stub, 'discardDraft()');
       assert.equal(stub.lastCall.firstArg, element.comment.id);
-      assert.isTrue(element.editing);
-      assert.isTrue(element.saving);
+      assert.isFalse(element.editing);
 
       discardPromise.resolve();
       await element.updateComplete;
 
       assert.isFalse(element.editing);
-      assert.isFalse(element.saving);
     });
 
     test('resolved comment state indicated by checkbox', async () => {
       const saveStub = sinon.stub(commentsModel, 'saveDraft');
       element.comment = {
         ...createComment(),
-        __draft: true,
+        savingState: SavingState.OK,
         unresolved: false,
       };
       await element.updateComplete;
@@ -752,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;
     });
@@ -778,32 +783,43 @@
     });
 
     test('saving while auto saving', async () => {
+      saveStub.reset();
+      const autoSavePromise = mockPromise<DraftInfo>();
+      saveStub.onCall(0).returns(autoSavePromise);
+      saveStub.onCall(1).returns(savePromise);
+
       const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
       dispatch(textarea, 'text-changed', {value: 'auto save text'});
 
       clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
-      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.callCount, 1);
       assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
-      saveStub.reset();
 
       element.messageText = 'actual save text';
       const save = element.save();
       await element.updateComplete;
       // First wait for the auto saving to finish.
-      assert.isFalse(saveStub.called);
+      assert.equal(saveStub.callCount, 1);
 
-      // Resolve auto-saving promise.
-      savePromise.resolve({
+      autoSavePromise.resolve({
         ...element.comment,
-        __draft: true,
+        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,
+        savingState: SavingState.OK,
+        message: 'actual save text',
+        id: 'exp123' as UrlEncodedCommentId,
+        updated: '2018-02-13 22:48:49.018000000' as Timestamp,
+      });
       await save;
       // Only then save.
-      assert.isTrue(saveStub.called);
-      assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
-      assert.equal(saveStub.firstCall.firstArg.id, 'exp123');
+      assert.equal(saveStub.callCount, 2);
+      assert.equal(saveStub.lastCall.firstArg.message, 'actual save text');
+      assert.equal(saveStub.lastCall.firstArg.id, 'exp123');
     });
   });
 
@@ -819,7 +835,7 @@
         },
         line: 5,
         path: 'test',
-        __draft: true,
+        savingState: SavingState.OK,
         message: 'hello world',
       };
       element = await fixture(
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 5b26ede..292da58 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -17,6 +17,8 @@
 import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {GrIcon} from '../gr-icon/gr-icon';
+import {getAppContext} from '../../../services/app-context';
+import {Timing} from '../../../constants/reporting';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -46,6 +48,8 @@
   @query('#icon')
   iconEl!: GrIcon;
 
+  private readonly reporting = getAppContext().reportingService;
+
   static override get styles() {
     return [
       css`
@@ -141,7 +145,12 @@
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
     this.iconEl.icon = 'check';
-    copyToClipbard(this.text, this.copyTargetName ?? 'Link');
+    this.reporting.time(Timing.COPY_TO_CLIPBOARD);
+    copyToClipbard(this.text, this.copyTargetName ?? 'Link').finally(() => {
+      this.reporting.timeEnd(Timing.COPY_TO_CLIPBOARD, {
+        copyTargetName: this.copyTargetName,
+      });
+    });
     setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index e230faf..e880531 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -21,7 +21,11 @@
 import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {getAppContext} from '../../../services/app-context';
-import {USER_SUGGESTION_INFO_STRING} from '../../../utils/comment-util';
+import {
+  getUserSuggestionFromString,
+  USER_SUGGESTION_INFO_STRING,
+} from '../../../utils/comment-util';
+import {sameOrigin} from '../../../utils/url-util';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -200,9 +204,8 @@
         /* HTML */
         `<a
           href="${href}"
-          target="_blank"
+          ${sameOrigin(href) ? '' : 'target="_blank" rel="noopener"'}
           ${title ? `title="${title}"` : ''}
-          rel="noopener"
           >${text}</a
         >`;
       renderer['image'] = (href: string, _title: string, text: string) =>
@@ -298,9 +301,16 @@
   }
 
   private convertCodeToSuggestions() {
-    for (const userSuggestionMark of this.renderRoot.querySelectorAll('mark')) {
+    const marks = this.renderRoot.querySelectorAll('mark');
+    if (marks.length > 0) {
+      const userSuggestionMark = marks[0];
       const userSuggestion = document.createElement('gr-user-suggestion-fix');
-      userSuggestion.textContent = userSuggestionMark.textContent ?? '';
+      // Temporary workaround for bug - tabs replacement
+      if (this.content.includes('\t')) {
+        userSuggestion.textContent = getUserSuggestionFromString(this.content);
+      } else {
+        userSuggestion.textContent = userSuggestionMark.textContent ?? '';
+      }
       userSuggestionMark.parentNode?.replaceChild(
         userSuggestion,
         userSuggestionMark
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 81048a3..9d7f06e 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -502,7 +502,11 @@
     });
 
     test('renders inline links into <a> tags', async () => {
-      element.content = '[myLink](https://www.google.com)';
+      const origin = window.location.origin;
+      element.content = `[myLink1](https://www.google.com)
+        [myLink2](/destiny)
+        [myLink3](${origin}/destiny)
+      `;
       await element.updateComplete;
 
       assert.shadowDom.equal(
@@ -512,8 +516,12 @@
             <div slot="markdown-html" class="markdown-html">
               <p>
                 <a href="https://www.google.com" rel="noopener" target="_blank"
-                  >myLink</a
+                  >myLink1</a
                 >
+                <br />
+                <a href="/destiny">myLink2</a>
+                <br />
+                <a href="${origin}/destiny">myLink3</a>
               </p>
             </div>
           </marked-element>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
index 14c2c15..394015e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -43,7 +43,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {createSearchUrl} from '../../../models/views/search';
 import {createDashboardUrl} from '../../../models/views/dashboard';
-import {fire} from '../../../utils/event-util';
+import {fire, fireReload} from '../../../utils/event-util';
 import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-hovercard-account-contents')
@@ -445,7 +445,7 @@
               this.getReviewerState(this.change!)
           );
         }
-        fire(this, 'reload', {clearPatchset: true});
+        fireReload(this);
       });
   }
 
@@ -464,7 +464,7 @@
         if (!response || !response.ok) {
           throw new Error('something went wrong when removing user');
         }
-        fire(this, 'reload', {clearPatchset: true});
+        fireReload(this);
         return response;
       });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index ae93977..4977ec5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -56,7 +56,6 @@
         <gr-change-actions></gr-change-actions>
       `);
       element.change = {} as ChangeViewChangeInfo;
-      element._hasKnownChainState = false;
       window.Gerrit.install(
         p => {
           plugin = p;
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
index 63a1dc4..fb6372c 100644
--- a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
@@ -30,6 +30,7 @@
           display: inline-block;
           vertical-align: top;
           line-height: var(--line-height-normal);
+          margin-right: var(--spacing-s);
         }
         a {
           color: var(--link-color);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 5267f307..cc45e1e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -3,16 +3,13 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {GrDiffBuilder} from './gr-diff-builder';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {GrDiffLineType} from '../../../api/diff';
-import {queryAndAssert} from '../../../utils/common-util';
 import {html, render} from 'lit';
 
-export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
+export class GrDiffBuilderBinary extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
@@ -25,17 +22,21 @@
     const section = createElementDiff('tbody', 'binary-diff');
     // Do not create a diff row for 'LOST'.
     if (group.lines[0].beforeNumber !== 'FILE') return section;
+    return super.buildSectionElement(group);
+  }
 
-    const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
-    const fileRow = this.createRow(line);
-    const contentTd = queryAndAssert<HTMLTableCellElement>(
-      fileRow,
-      'td.both.file'
-    )!;
-    const div = document.createElement('div');
-    render(html`<span>Difference in binary files</span>`, div);
-    contentTd.insertBefore(div, contentTd.firstChild);
-    section.appendChild(fileRow);
-    return section;
+  public renderBinaryDiff() {
+    render(
+      html`
+        <tbody class="gr-diff binary-diff">
+          <tr class="gr-diff">
+            <td colspan="5" class="gr-diff">
+              <span>Difference in binary files</span>
+            </td>
+          </tr>
+        </tbody>
+      `,
+      this.outputEl
+    );
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 7fa6d72..328b577 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -5,18 +5,15 @@
  */
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import './gr-diff-builder-side-by-side';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {
-  DiffBuilder,
-  ImageDiffBuilder,
+  GrDiffBuilder,
   DiffContextExpandedEventDetail,
   isImageDiffBuilder,
+  isBinaryDiffBuilder,
 } from './gr-diff-builder';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {GrDiffBuilderLit} from './gr-diff-builder-lit';
-import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -117,7 +114,7 @@
   layers: DiffLayer[] = [];
 
   // visible for testing
-  builder?: DiffBuilder | ImageDiffBuilder;
+  builder?: GrDiffBuilder;
 
   /**
    * All layers, both from the outside and the default ones. See `layers` for
@@ -132,13 +129,6 @@
   // visible for testing
   showTrailingWhitespace?: boolean;
 
-  /**
-   * The promise last returned from `render()` while the asynchronous
-   * rendering is running - `null` otherwise. Provides a `cancel()`
-   * method that rejects it with `{isCancelled: true}`.
-   */
-  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
-
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
   private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
@@ -146,7 +136,7 @@
   private rangeLayer?: GrRangedCommentLayer;
 
   // visible for testing
-  processor = new GrDiffProcessor();
+  processor?: GrDiffProcessor;
 
   /**
    * Groups are mostly just passed on to the diff builder (this.builder). But
@@ -158,10 +148,6 @@
    */
   private groups: GrDiffGroup[] = [];
 
-  constructor() {
-    this.processor.consumer = this;
-  }
-
   updateCommentRanges(ranges: CommentRangeLayer[]) {
     this.rangeLayer?.updateRanges(ranges);
   }
@@ -188,8 +174,16 @@
     this.builder = this.getDiffBuilder();
     this.init();
 
+    // TODO: Just pass along the diff model here instead of setting many
+    // individual properties.
+    this.processor = new GrDiffProcessor();
+    this.processor.consumer = this;
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
+    if (this.renderPrefs?.num_lines_rendered_at_once) {
+      this.processor.asyncThreshold =
+        this.renderPrefs.num_lines_rendered_at_once;
+    }
 
     this.clearDiffContent();
     this.builder.addColumns(
@@ -200,17 +194,14 @@
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
     fire(this.diffElement, 'render-start', {});
-    // TODO: processor.process() returns a cancelable promise already.
-    // Why wrap another one around it?
-    this.cancelableRenderPromise = makeCancelable(
-      this.processor.process(this.diff.content, isBinary)
-    );
-    // All then/catch/finally clauses must be outside of makeCancelable().
     return (
-      this.cancelableRenderPromise
+      this.processor
+        .process(this.diff.content, isBinary)
         .then(async () => {
           if (isImageDiffBuilder(this.builder)) {
             this.builder.renderImageDiff();
+          } else if (isBinaryDiffBuilder(this.builder)) {
+            this.builder.renderBinaryDiff();
           }
           await this.untilGroupsRendered();
           fire(this.diffElement, 'render-content', {});
@@ -222,9 +213,6 @@
           if (!e.isCanceled) return Promise.reject(e);
           return;
         })
-        .finally(() => {
-          this.cancelableRenderPromise = null;
-        })
     );
   }
 
@@ -261,31 +249,21 @@
     this.layersInternal = layers;
   }
 
-  getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this.builder) return null;
-    return this.builder.getContentTdByLine(lineNumber, side, root);
+  getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return undefined;
+    return this.builder.getContentTdByLine(lineNumber, side);
   }
 
-  private getDiffRowByChild(child: Element) {
-    while (!child.classList.contains('diff-row') && child.parentElement) {
-      child = child.parentElement;
-    }
-    return child;
-  }
-
-  getContentTdByLineEl(lineEl?: Element): Element | null {
-    if (!lineEl) return null;
+  getContentTdByLineEl(lineEl?: Element): Element | undefined {
+    if (!lineEl) return undefined;
     const line = getLineNumber(lineEl);
-    if (!line) return null;
+    if (!line) return undefined;
     const side = getSideByLineEl(lineEl);
-    // Performance optimization because we already have an element in the
-    // correct row
-    const row = this.getDiffRowByChild(lineEl);
-    return this.getContentTdByLine(line, side, row);
+    return this.getContentTdByLine(line, side);
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return null;
+    if (!this.builder) return undefined;
     return this.builder.getLineElByNumber(lineNumber, side);
   }
 
@@ -386,10 +364,8 @@
    * gr-diff re-connects.
    */
   cleanup() {
-    this.processor.cancel();
+    this.processor?.cancel();
     this.builder?.cleanup();
-    this.cancelableRenderPromise?.cancel();
-    this.cancelableRenderPromise = null;
     this.diffElement?.removeEventListener(
       'diff-context-expanded',
       this.onDiffContextExpanded
@@ -407,7 +383,7 @@
   }
 
   // visible for testing
-  getDiffBuilder(): DiffBuilder {
+  getDiffBuilder(): GrDiffBuilder {
     assertIsDefined(this.diff, 'diff');
     assertIsDefined(this.diffElement, 'diff table');
     if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
@@ -437,14 +413,13 @@
         this.useNewImageDiffUi
       );
     } else if (this.diff.binary) {
-      // If the diff is binary, but not an image.
       return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
     } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
       this.renderPrefs = {
         ...this.renderPrefs,
         view_mode: DiffViewMode.SIDE_BY_SIDE,
       };
-      builder = new GrDiffBuilderLit(
+      builder = new GrDiffBuilder(
         this.diff,
         localPrefs,
         this.diffElement,
@@ -456,7 +431,7 @@
         ...this.renderPrefs,
         view_mode: DiffViewMode.UNIFIED,
       };
-      builder = new GrDiffBuilderLit(
+      builder = new GrDiffBuilder(
         this.diff,
         localPrefs,
         this.diffElement,
@@ -595,6 +570,5 @@
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     this.builder?.updateRenderPrefs(renderPrefs);
-    this.processor.updateRenderPrefs(renderPrefs);
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index ba6acfd..da2e9f1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -12,7 +12,6 @@
 import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {
   DiffContent,
   DiffLayer,
@@ -21,26 +20,24 @@
   Side,
 } from '../../../api/diff';
 import {stubRestApi} from '../../../test/test-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
 import {waitForEventOnce} from '../../../utils/event-util';
 import {GrDiffBuilderElement} from './gr-diff-builder-element';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {BlameInfo} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrDiffRow} from './gr-diff-row';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {querySelectorAll} from '../../../utils/dom-util';
 
 const DEFAULT_PREFS = createDefaultDiffPrefs();
 
 suite('gr-diff-builder tests', () => {
   let element: GrDiffBuilderElement;
-  let builder: GrDiffBuilderLegacy;
+  let builder: GrDiffBuilder;
   let diffTable: HTMLTableElement;
 
-  const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
-
   const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
-    builder = new GrDiffBuilderSideBySide(
+    builder = new GrDiffBuilder(
       createEmptyDiff(),
       {...createDefaultDiffPrefs(), ...prefs},
       diffTable
@@ -63,15 +60,6 @@
     setBuilderPrefs({});
   });
 
-  test('line_length applied with line break if line_wrapping is false', () => {
-    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
-    const text = 'a'.repeat(51);
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder.createTextEl(null, line(text)).firstElementChild
-      ?.firstElementChild?.innerHTML;
-    assert.equal(result, expected);
-  });
-
   [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
     test(`line_length used for regular files under ${mode}`, () => {
       element.path = '/a.txt';
@@ -82,8 +70,8 @@
         tab_size: 4,
         line_length: 50,
       };
-      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
-      assert.equal(builder._prefs.line_length, 50);
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 50);
     });
 
     test(`line_length ignored for commit msg under ${mode}`, () => {
@@ -95,26 +83,11 @@
         tab_size: 4,
         line_length: 50,
       };
-      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
-      assert.equal(builder._prefs.line_length, 72);
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 72);
     });
   });
 
-  test('createTextEl linewrap with tabs', () => {
-    setBuilderPrefs({tab_size: 4, line_length: 10});
-    const text = '\t'.repeat(7) + '!';
-    const el = builder.createTextEl(null, line(text));
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 4, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
-      newlineEl
-    );
-  });
-
   test('_handlePreferenceError throws with invalid preference', () => {
     element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
     assert.throws(() => element.getDiffBuilder());
@@ -498,15 +471,11 @@
   });
 
   suite('rendering text, images and binary files', () => {
-    let processStub: sinon.SinonStub;
     let keyLocations: KeyLocations;
     let content: DiffContent[] = [];
 
     setup(() => {
       element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon
-        .stub(element.processor, 'process')
-        .returns(Promise.resolve());
       keyLocations = {left: {}, right: {}};
       element.prefs = {
         ...DEFAULT_PREFS,
@@ -531,8 +500,7 @@
       element.diff = {...createEmptyDiff(), content};
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isFalse(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
     });
 
     test('image', async () => {
@@ -540,105 +508,14 @@
       element.isImageDiff = true;
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
     });
 
     test('binary', async () => {
       element.diff = {...createEmptyDiff(), content, binary: true};
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
-    });
-  });
-
-  suite('rendering', () => {
-    let content: DiffContent[];
-    let outputEl: HTMLTableElement;
-    let keyLocations: KeyLocations;
-    let addColumnsStub: sinon.SinonStub;
-    let dispatchStub: sinon.SinonStub;
-    let builder: GrDiffBuilderSideBySide;
-
-    setup(() => {
-      const prefs = {...DEFAULT_PREFS};
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
-      outputEl = element.diffElement!;
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
-        builder = new GrDiffBuilderSideBySide(
-          {...createEmptyDiff(), content},
-          prefs,
-          outputEl
-        );
-        addColumnsStub = sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function (group) {
-          const section = document.createElement('stub');
-          section.style.display = 'block';
-          section.textContent = group.lines.reduce(
-            (acc, line) => acc + line.text,
-            ''
-          );
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {...createEmptyDiff(), content};
-      element.prefs = prefs;
-      element.render(keyLocations);
-    });
-
-    test('addColumns is called', () => {
-      assert.isTrue(addColumnsStub.called);
-    });
-
-    test('getGroupsByLineRange one line', () => {
-      const section = outputEl.querySelector<HTMLElement>(
-        'stub:nth-of-type(3)'
-      );
-      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
-      assert.equal(groups.length, 1);
-      assert.strictEqual(groups[0].element, section);
-    });
-
-    test('getGroupsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
-        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
-      ];
-      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
-      assert.equal(groups.length, 2);
-      assert.strictEqual(groups[0].element, section[0]);
-      assert.strictEqual(groups[1].element, section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      await waitUntil(() => dispatchStub.callCount >= 1);
-      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-
-      await waitUntil(() => dispatchStub.callCount >= 2);
-      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel cancels the processor', () => {
-      const processorCancelStub = sinon.stub(element.processor, 'cancel');
-      element.cleanup();
-      assert.isTrue(processorCancelStub.called);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
     });
   });
 
@@ -747,101 +624,4 @@
       assert.include(firedEventTypes, 'render-content');
     });
   });
-
-  suite('blame', () => {
-    let mockBlame: BlameInfo[];
-
-    setup(() => {
-      mockBlame = [
-        {
-          author: 'test-author',
-          time: 314,
-          commit_msg: 'test-commit-message',
-          id: 'commit 1',
-          ranges: [
-            {start: 1, end: 2},
-            {start: 10, end: 16},
-          ],
-        },
-        {
-          author: 'test-author',
-          time: 314,
-          commit_msg: 'test-commit-message',
-          id: 'commit 2',
-          ranges: [
-            {start: 4, end: 10},
-            {start: 17, end: 32},
-          ],
-        },
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon
-        .stub(builder, 'getBlameTdByLine')
-        .returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder.getBlameCommitForBaseLine(1));
-      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(11));
-      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
-
-      assert.isOk(builder.getBlameCommitForBaseLine(32));
-      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
-
-      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
-    });
-
-    test('getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
-      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
-    });
-
-    test('createBlameCell', () => {
-      const mockBlameInfo = {
-        time: 1576155200,
-        id: '1234567890',
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [{start: 4, end: 10}],
-      };
-      const getBlameStub = sinon
-        .stub(builder, 'getBlameCommitForBaseLine')
-        .returns(mockBlameInfo);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder.createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      assert.dom.equal(
-        result,
-        /* HTML */ `
-          <span class="gr-diff">
-            <a class="blameDate gr-diff" href="/r/q/1234567890"> 12/12/2019 </a>
-            <span class="blameAuthor gr-diff">Clark</span>
-            <gr-hovercard class="gr-diff">
-              <span class="blameHoverCard gr-diff">
-                Commit 1234567890<br />
-                Author: Clark Kent<br />
-                Date: 12/12/2019<br />
-                <br />
-                Testing Commit
-              </span>
-            </gr-hovercard>
-          </span>
-        `
-      );
-    });
-  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 0ea904a..3bffe08 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -3,23 +3,19 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {RenderPreferences, Side} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
-import {ImageDiffBuilder} from './gr-diff-builder';
 import {html, LitElement, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
+import {GrDiffBuilder} from './gr-diff-builder';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
 const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
-export class GrDiffBuilderImage
-  extends GrDiffBuilderSideBySide
-  implements ImageDiffBuilder
-{
+export class GrDiffBuilderImage extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
deleted file mode 100644
index e786cf4..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ /dev/null
@@ -1,518 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {RenderPreferences} from '../../../api/diff';
-import {fire} from '../../../utils/event-util';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import '../gr-context-controls/gr-context-controls';
-import {
-  GrContextControls,
-  GrContextControlsShowConfig,
-} from '../gr-context-controls/gr-context-controls';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {
-  createBlameElement,
-  createElementDiff,
-  createElementDiffWithText,
-  formatText,
-  getResponsiveMode,
-} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {BlameInfo} from '../../../types/common';
-
-function lineTdSelector(lineNumber: LineNumber, side?: Side): string {
-  const sideSelector = side ? `.${side}` : '';
-  return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`;
-}
-/**
- * Base class for builders that are creating the DOM elements programmatically
- * by calling `document.createElement()` and such. We are calling such builders
- * "legacy", because we want to create (Lit) component based diff elements.
- *
- * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
- */
-export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  override getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root: Element = this.outputEl
-  ): HTMLTableCellElement | null {
-    return root.querySelector<HTMLTableCellElement>(
-      `${lineTdSelector(lineNumber, side)} ~ td.content`
-    );
-  }
-
-  override getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | null {
-    return this.outputEl.querySelector<HTMLTableCellElement>(
-      lineTdSelector(lineNumber, side)
-    );
-  }
-
-  override getLineNumberRows() {
-    return Array.from(
-      this.outputEl.querySelectorAll<HTMLTableRowElement>(
-        ':not(.contextControl) > .diff-row'
-      ) ?? []
-    ).filter(tr => tr.querySelector('button'));
-  }
-
-  override getLineNumEls(side: Side): HTMLTableCellElement[] {
-    return Array.from(
-      this.outputEl.querySelectorAll<HTMLTableCellElement>(
-        `td.lineNum.${side}`
-      ) ?? []
-    );
-  }
-
-  override getBlameTdByLine(lineNum: number): Element | undefined {
-    return (
-      this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
-      undefined
-    );
-  }
-
-  override getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: HTMLElement
-  ): HTMLElement | null {
-    const td = this.getContentTdByLine(lineNumber, side, root);
-    return td ? td.querySelector('.contentText') : null;
-  }
-
-  override renderContentByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ) {
-    const lines: GrDiffLine[] = [];
-    const elements: HTMLElement[] = [];
-    let line;
-    let el;
-    this.findLinesByRange(start, end, side, lines, elements);
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      el = elements[i];
-      if (!el || !el.parentElement) {
-        // Cannot re-render an element if it does not exist. This can happen
-        // if lines are collapsed and not visible on the page yet.
-        continue;
-      }
-      const lineNumberEl = this.getLineNumberEl(el, side);
-      const newContent = this.createTextEl(lineNumberEl, line, side)
-        .firstChild as HTMLElement;
-      // Note that ${el.id} ${newContent.id} might actually mismatch: In unified
-      // diff we are rendering the same content twice for all the diff chunk
-      // that are unchanged from left to right. TODO: Be smarter about this.
-      el.parentElement.replaceChild(newContent, el);
-    }
-  }
-
-  override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
-    for (let i = start; i <= end; i++) {
-      // TODO(wyatta): this query is expensive, but, when traversing a
-      // range, the lines are consecutive, and given the previous blame
-      // cell, the next one can be reached cheaply.
-      const blameCell = this.getBlameTdByLine(i);
-      if (!blameCell) continue;
-
-      // Remove the element's children (if any).
-      while (blameCell.hasChildNodes()) {
-        blameCell.removeChild(blameCell.lastChild!);
-      }
-      const blameEl = createBlameElement(i, blame);
-      if (blameEl) blameCell.appendChild(blameEl);
-    }
-  }
-
-  /**
-   * Finds the line number element given the content element by walking up the
-   * DOM tree to the diff row and then querying for a .lineNum element on the
-   * requested side.
-   *
-   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
-   */
-  // visible for testing
-  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
-    let row: HTMLElement | null = content;
-    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
-    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
-  }
-
-  /**
-   * Adds <tr> table rows to a <tbody> section for allowing the user to expand
-   * collapsed of lines. Called by subclasses.
-   */
-  protected createContextControls(
-    section: HTMLElement,
-    group: GrDiffGroup,
-    viewMode: DiffViewMode
-  ) {
-    const leftStart = group.lineRange.left.start_line;
-    const leftEnd = group.lineRange.left.end_line;
-    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
-    const lastGroupIsSkipped =
-      !!group.contextGroups[group.contextGroups.length - 1].skip;
-
-    const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
-    const showAbove =
-      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
-    const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;
-
-    if (showAbove) {
-      const paddingRow = this.createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('above');
-      section.appendChild(paddingRow);
-    }
-    section.appendChild(
-      this.createContextControlRow(group, showAbove, showBelow, viewMode)
-    );
-    if (showBelow) {
-      const paddingRow = this.createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('below');
-      section.appendChild(paddingRow);
-    }
-  }
-
-  /**
-   * Creates a context control <tr> table row for with buttons the allow the
-   * user to expand collapsed lines. Buttons extend from the gap created by this
-   * method up or down into the area of code that they affect.
-   */
-  private createContextControlRow(
-    group: GrDiffGroup,
-    showAbove: boolean,
-    showBelow: boolean,
-    viewMode: DiffViewMode
-  ): HTMLElement {
-    const row = createElementDiff('tr', 'dividerRow');
-    let showConfig: GrContextControlsShowConfig;
-    if (showAbove && !showBelow) {
-      showConfig = 'above';
-    } else if (!showAbove && showBelow) {
-      showConfig = 'below';
-    } else {
-      // Note that !showAbove && !showBelow also intentionally creates
-      // "show-both". This means the file is completely collapsed, which is
-      // unusual, but at least happens in one test.
-      showConfig = 'both';
-    }
-    row.classList.add(`show-${showConfig}`);
-
-    row.appendChild(this.createBlameCell(0));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td'));
-    }
-
-    const cell = createElementDiff('td', 'dividerCell');
-    // Note that <td> table cells that have `display: none` don't count!
-    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
-    cell.setAttribute('colspan', colspan);
-    row.appendChild(cell);
-
-    const contextControls = createElementDiff(
-      'gr-context-controls'
-    ) as GrContextControls;
-    contextControls.diff = this._diff;
-    contextControls.renderPreferences = this.renderPrefs;
-    contextControls.group = group;
-    contextControls.showConfig = showConfig;
-    cell.appendChild(contextControls);
-    return row;
-  }
-
-  /**
-   * Creates a table row to serve as padding between code and context controls.
-   * Blame column, line gutters, and content area will continue visually, but
-   * context controls can render over this background to map more clearly to
-   * the area of code they expand.
-   */
-  private createContextControlPaddingRow(viewMode: DiffViewMode) {
-    const row = createElementDiff('tr', 'contextBackground');
-
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.classList.add('side-by-side');
-      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
-      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
-    } else {
-      row.classList.add('unified');
-    }
-
-    row.appendChild(this.createBlameCell(0));
-    row.appendChild(createElementDiff('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td', 'sign'));
-      row.appendChild(createElementDiff('td'));
-    }
-    row.appendChild(createElementDiff('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td', 'sign'));
-    }
-    row.appendChild(createElementDiff('td'));
-
-    return row;
-  }
-
-  protected createLineEl(
-    line: GrDiffLine,
-    number: LineNumber,
-    type: GrDiffLineType,
-    side: Side
-  ) {
-    const td = createElementDiff('td');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      td.classList.add('blankLineNum');
-      return td;
-    }
-    if (line.type === GrDiffLineType.BOTH || line.type === type) {
-      td.classList.add('lineNum');
-      td.dataset['value'] = number.toString();
-
-      if (
-        ((this._prefs.show_file_comment_button === false ||
-          this.renderPrefs?.show_file_comment_button === false) &&
-          number === 'FILE') ||
-        number === 'LOST'
-      ) {
-        return td;
-      }
-
-      const button = createElementDiff('button');
-      td.appendChild(button);
-      button.tabIndex = -1;
-      button.classList.add('lineNumButton');
-      button.classList.add(side);
-      button.dataset['value'] = number.toString();
-      button.id =
-        side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`;
-      button.textContent = number === 'FILE' ? 'File' : number.toString();
-      if (number === 'FILE') {
-        button.setAttribute('aria-label', 'Add file comment');
-      }
-
-      // Add aria-labels for valid line numbers.
-      // For unified diff, this method will be called with number set to 0 for
-      // the empty line number column for added/removed lines. This should not
-      // be announced to the screenreader.
-      if (number !== 'FILE' && number > 0) {
-        if (line.type === GrDiffLineType.REMOVE) {
-          button.setAttribute('aria-label', `${number} removed`);
-        } else if (line.type === GrDiffLineType.ADD) {
-          button.setAttribute('aria-label', `${number} added`);
-        } else {
-          button.setAttribute('aria-label', `${number} unmodified`);
-        }
-      }
-      this.addLineNumberMouseEvents(td, number, side);
-    }
-    return td;
-  }
-
-  private addLineNumberMouseEvents(
-    el: HTMLElement,
-    number: LineNumber,
-    side: Side
-  ) {
-    el.addEventListener('mouseenter', () => {
-      fire(el, 'line-mouse-enter', {lineNum: number, side});
-    });
-    el.addEventListener('mouseleave', () => {
-      fire(el, 'line-mouse-leave', {lineNum: number, side});
-    });
-  }
-
-  // visible for testing
-  createTextEl(
-    lineNumberEl: HTMLElement | null,
-    line: GrDiffLine,
-    side?: Side,
-    twoSlots?: boolean
-  ) {
-    const td = createElementDiff('td');
-    if (line.type !== GrDiffLineType.BLANK) {
-      td.classList.add('content');
-    }
-    if (side) {
-      td.classList.add(side);
-    }
-
-    // If intraline info is not available, the entire line will be
-    // considered as changed and marked as dark red / green color
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    td.classList.add(line.type);
-
-    const lineNumber = side ? line.lineNumber(side) : 0;
-    if (lineNumber === 'FILE') {
-      td.classList.add('file');
-    } else if (lineNumber === 'LOST') {
-      td.classList.add('lost');
-    } else {
-      const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
-      const contentId =
-        side && lineNumber > 0 ? `${side}-content-${lineNumber}` : '';
-      const contentText = formatText(
-        line.text,
-        responsiveMode,
-        this._prefs.tab_size,
-        this._prefs.line_length,
-        contentId
-      );
-
-      if (side) {
-        contentText.setAttribute('data-side', side);
-        this.addLineNumberMouseEvents(td, lineNumber, side);
-      }
-
-      if (lineNumberEl && side) {
-        for (const layer of this.layers) {
-          if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line, side);
-          }
-        }
-      } else {
-        console.error('lineNumberEl or side not set, skipping layer.annotate');
-      }
-
-      td.appendChild(contentText);
-    }
-
-    if (side && lineNumber) {
-      const threadGroupEl = document.createElement('div');
-      threadGroupEl.className = 'thread-group';
-      threadGroupEl.setAttribute('data-side', side);
-
-      const slot = document.createElement('slot');
-      slot.name = `${side}-${lineNumber}`;
-      threadGroupEl.appendChild(slot);
-
-      // For line.type === BOTH in unified diff we want two slots.
-      if (twoSlots) {
-        const slot = document.createElement('slot');
-        const otherSide = side === Side.LEFT ? Side.RIGHT : Side.LEFT;
-        slot.name = `${otherSide}-${line.lineNumber(otherSide)}`;
-        threadGroupEl.appendChild(slot);
-      }
-
-      td.appendChild(threadGroupEl);
-    }
-
-    return td;
-  }
-
-  private createMovedLineAnchor(line: number, side: Side) {
-    const anchor = createElementDiffWithText('a', `${line}`);
-
-    // href is not actually used but important for Screen Readers
-    anchor.setAttribute('href', `#${line}`);
-    anchor.addEventListener('click', e => {
-      e.preventDefault();
-      fire(anchor, 'moved-link-clicked', {
-        lineNum: line,
-        side,
-      });
-    });
-    return anchor;
-  }
-
-  private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
-    const div = createElementDiff('div');
-    if (group.moveDetails?.range) {
-      const {changed, range} = group.moveDetails;
-      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
-      const andChangedLabel = changed ? 'and changed ' : '';
-      const direction = movedIn ? 'from' : 'to';
-      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
-      div.appendChild(createElementDiffWithText('span', textLabel));
-      div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
-      div.appendChild(createElementDiffWithText('span', ' - '));
-      div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
-    } else {
-      div.appendChild(
-        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
-      );
-    }
-    return div;
-  }
-
-  protected buildMoveControls(group: GrDiffGroup) {
-    const movedIn = group.adds.length > 0;
-    const {
-      numberOfCells,
-      movedOutIndex,
-      movedInIndex,
-      lineNumberCols,
-      signCols,
-    } = this.getMoveControlsConfig();
-
-    let controlsClass;
-    let descriptionIndex;
-    const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group);
-    if (movedIn) {
-      controlsClass = 'movedIn';
-      descriptionIndex = movedInIndex;
-    } else {
-      controlsClass = 'movedOut';
-      descriptionIndex = movedOutIndex;
-    }
-
-    const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
-    const cells = [...Array(numberOfCells).keys()].map(() =>
-      createElementDiff('td')
-    );
-    lineNumberCols.forEach(index => {
-      cells[index].classList.add('moveControlsLineNumCol');
-    });
-
-    if (signCols) {
-      cells[signCols.left].classList.add('sign', 'left');
-      cells[signCols.right].classList.add('sign', 'right');
-    }
-    const moveRangeHeader = createElementDiff('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'move_item');
-    moveRangeHeader.appendChild(descriptionTextDiv);
-    cells[descriptionIndex].classList.add('moveHeader');
-    cells[descriptionIndex].appendChild(moveRangeHeader);
-    cells.forEach(c => {
-      controls.appendChild(c);
-    });
-    return controls;
-  }
-
-  /**
-   * Create a blame cell for the given base line. Blame information will be
-   * included in the cell if available.
-   */
-  // visible for testing
-  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
-    const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
-    blameTd.setAttribute('data-line-number', lineNumber.toString());
-    if (!lineNumber) return blameTd;
-
-    const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
-    if (!blameInfo) return blameTd;
-
-    blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
-    return blameTd;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
deleted file mode 100644
index 2a61ef2..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {DiffViewMode, RenderPreferences} from '../../../api/diff';
-import {LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {Side} from '../../../constants/constants';
-import {DiffLayer, isDefined} from '../../../types/types';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {BlameInfo} from '../../../types/common';
-import {html, nothing, render} from 'lit';
-import {GrDiffSection} from './gr-diff-section';
-import '../gr-context-controls/gr-context-controls';
-import './gr-diff-section';
-import {GrDiffRow} from './gr-diff-row';
-
-/**
- * Base class for builders that are creating the diff using Lit elements.
- */
-export class GrDiffBuilderLit extends GrDiffBuilder {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  override getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    _root: Element = this.outputEl
-  ): HTMLTableCellElement | null {
-    if (!side) return null;
-    const row = this.findRow(lineNumber, side);
-    return row?.getContentCell(side) ?? null;
-  }
-
-  override getLineElByNumber(lineNumber: LineNumber, side: Side) {
-    const row = this.findRow(lineNumber, side);
-    return row?.getLineNumberCell(side) ?? null;
-  }
-
-  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
-    if (!side || !lineNumber) return undefined;
-    const group = this.findGroup(side, lineNumber);
-    if (!group) return undefined;
-    const section = this.findSection(group);
-    if (!section) return undefined;
-    return section.findRow(side, lineNumber);
-  }
-
-  private getDiffRows() {
-    const sections = [
-      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
-    ];
-    return sections.map(s => s.getDiffRows()).flat();
-  }
-
-  override getLineNumberRows(): HTMLTableRowElement[] {
-    const rows = this.getDiffRows();
-    return rows.map(r => r.getTableRow()).filter(isDefined);
-  }
-
-  override getLineNumEls(side: Side): HTMLTableCellElement[] {
-    const rows = this.getDiffRows();
-    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
-  }
-
-  override getBlameTdByLine(lineNumber: number): Element | undefined {
-    return this.findRow(lineNumber, Side.LEFT)?.getBlameCell();
-  }
-
-  override getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    _root?: HTMLElement
-  ): HTMLElement | null {
-    const cell = this.getContentTdByLine(lineNumber, side);
-    return (cell?.firstChild ?? null) as HTMLElement | null;
-  }
-
-  /** This is used when layers initiate an update. */
-  override renderContentByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ) {
-    const groups = this.getGroupsByLineRange(start, end, side);
-    for (const group of groups) {
-      const section = this.findSection(group);
-      for (const row of section?.getDiffRows() ?? []) {
-        row.requestUpdate();
-      }
-    }
-  }
-
-  private findSection(group?: GrDiffGroup): GrDiffSection | undefined {
-    if (!group) return undefined;
-    const leftClass = `left-${group.startLine(Side.LEFT)}`;
-    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
-    return (
-      this.outputEl.querySelector<GrDiffSection>(
-        `gr-diff-section.${leftClass}.${rightClass}`
-      ) ?? undefined
-    );
-  }
-
-  override renderBlameByRange(
-    blameInfo: BlameInfo,
-    start: number,
-    end: number
-  ) {
-    for (let lineNumber = start; lineNumber <= end; lineNumber++) {
-      const row = this.findRow(lineNumber, Side.LEFT);
-      if (!row) continue;
-      row.blameInfo = blameInfo;
-    }
-  }
-
-  // TODO: Refactor this such that adding the move controls becomes part of the
-  // lit element.
-  protected override getMoveControlsConfig() {
-    return {
-      numberOfCells: 6, // How many cells does the diff table have?
-      movedOutIndex: 2, // Index of left content column in diff table.
-      movedInIndex: 5, // Index of right content column in diff table.
-      lineNumberCols: [0, 3], // Indices of line number columns in diff table.
-      signCols: {left: 1, right: 4},
-    };
-  }
-
-  protected override buildSectionElement(group: GrDiffGroup) {
-    const leftCl = `left-${group.startLine(Side.LEFT)}`;
-    const rightCl = `right-${group.startLine(Side.RIGHT)}`;
-    const section = html`
-      <gr-diff-section
-        class="${leftCl} ${rightCl}"
-        .group=${group}
-        .diff=${this._diff}
-        .layers=${this.layers}
-        .diffPrefs=${this._prefs}
-        .renderPrefs=${this.renderPrefs}
-      ></gr-diff-section>
-    `;
-    // When using Lit's `render()` method it wants to be in full control of the
-    // element that it renders into, so we let it render into a temp element.
-    // Rendering into the diff table directly would interfere with
-    // `clearDiffContent()`for example.
-    // TODO: Remove legacy diff builder, then convert <gr-diff> to be fully lit
-    // controlled, then this code will become part of the standard `render()` of
-    // <gr-diff> as a LitElement.
-    const tempEl = document.createElement('div');
-    render(section, tempEl);
-    const sectionEl = tempEl.firstElementChild as GrDiffSection;
-    return sectionEl;
-  }
-
-  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = html`
-      <colgroup>
-        <col class=${diffClasses('blame')}></col>
-        ${this.renderUnifiedColumns(lineNumberWidth)}
-        ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
-        ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
-      </colgroup>
-    `;
-    // When using Lit's `render()` method it wants to be in full control of the
-    // element that it renders into, so we let it render into a temp element.
-    // Rendering into the diff table directly would interfere with
-    // `clearDiffContent()`for example.
-    // TODO: Remove legacy diff builder, then convert <gr-diff> to be fully lit
-    // controlled, then this code will become part of the standard `render()` of
-    // <gr-diff> as a LitElement.
-    const tempEl = document.createElement('div');
-    render(colgroup, tempEl);
-    const colgroupEl = tempEl.firstElementChild as HTMLElement;
-    outputEl.appendChild(colgroupEl);
-  }
-
-  private renderUnifiedColumns(lineNumberWidth: number) {
-    if (this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED) return nothing;
-    return html`
-      <col class=${diffClasses()} width=${lineNumberWidth}></col>
-      <col class=${diffClasses()} width=${lineNumberWidth}></col>
-      <col class=${diffClasses()}></col>
-    `;
-  }
-
-  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
-    if (this.renderPrefs?.view_mode === DiffViewMode.UNIFIED) return nothing;
-    return html`
-      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
-      <col class=${diffClasses(side, 'sign')}></col>
-      <col class=${diffClasses(side)}></col>
-    `;
-  }
-
-  protected override getNextContentOnSide(
-    _content: HTMLElement,
-    _side: Side
-  ): HTMLElement | null {
-    // TODO: getNextContentOnSide() is not required by lit based rendering.
-    // So let's refactor it to be moved into gr-diff-builder-legacy.
-    console.warn('unimplemented method getNextContentOnSide() called');
-    return null;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
deleted file mode 100644
index 6ae7d4cd..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
-
-export class GrDiffBuilderSideBySide extends GrDiffBuilderLegacy {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  protected override getMoveControlsConfig() {
-    return {
-      numberOfCells: 6,
-      movedOutIndex: 2,
-      movedInIndex: 5,
-      lineNumberCols: [0, 3],
-      signCols: {left: 1, right: 4},
-    };
-  }
-
-  // visible for testing
-  override buildSectionElement(group: GrDiffGroup) {
-    const sectionEl = createElementDiff('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (group.isTotal()) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      if (group.moveDetails.changed) {
-        sectionEl.classList.add('changed');
-      }
-      sectionEl.appendChild(this.buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this.createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
-      return sectionEl;
-    }
-
-    const pairs = group.getSideBySidePairs();
-    for (let i = 0; i < pairs.length; i++) {
-      sectionEl.appendChild(this.createRow(pairs[i].left, pairs[i].right));
-    }
-    return sectionEl;
-  }
-
-  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = createElementDiff('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = createElementDiff('col', 'left');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    colgroup.appendChild(createElementDiff('col', 'sign left'));
-
-    // Add left-side content.
-    colgroup.appendChild(createElementDiff('col', 'left'));
-
-    // Add right-side line number.
-    col = createElementDiff('col', 'right');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    colgroup.appendChild(createElementDiff('col', 'sign right'));
-
-    // Add right-side content.
-    colgroup.appendChild(createElementDiff('col', 'right'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  private createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
-    const row = createElementDiff('tr');
-    row.classList.add('diff-row', 'side-by-side');
-    row.setAttribute('left-type', leftLine.type);
-    row.setAttribute('right-type', rightLine.type);
-    // TabIndex makes screen reader read a row when navigating with j/k
-    row.tabIndex = -1;
-    // Before Chrome 102, Chrome was able to compute a11y label from children
-    // content. Now Chrome 102 and Firefox are not computing a11y label because
-    // tr is not expected to have aria label. Adding aria role button is
-    // pushing browser to compute aria even for tr. This can be removed, once
-    // browsers will again compute a11y label even for tr when it is focused.
-    // TODO: Remove when Chrome 102 is out of date for 1 year.
-    row.setAttribute(
-      'aria-labelledby',
-      [
-        leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
-        leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
-        rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
-        rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
-      ]
-        .join(' ')
-        .trim()
-    );
-
-    row.appendChild(this.createBlameCell(leftLine.beforeNumber));
-
-    this.appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
-    this.appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
-    return row;
-  }
-
-  private appendPair(
-    row: HTMLElement,
-    line: GrDiffLine,
-    lineNumber: LineNumber,
-    side: Side
-  ) {
-    const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
-    row.appendChild(lineNumberEl);
-    row.appendChild(this.createSignEl(line, side));
-    row.appendChild(this.createTextEl(lineNumberEl, line, side));
-  }
-
-  private createSignEl(line: GrDiffLine, side: Side): HTMLElement {
-    const td = createElementDiff('td', 'sign');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      td.classList.add('blank');
-    } else if (line.type === GrDiffLineType.ADD && side === Side.RIGHT) {
-      td.classList.add('add');
-      td.innerText = '+';
-    } else if (line.type === GrDiffLineType.REMOVE && side === Side.LEFT) {
-      td.classList.add('remove');
-      td.innerText = '-';
-    }
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    return td;
-  }
-
-  // visible for testing
-  override getNextContentOnSide(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      const nextContent = tr.querySelector(
-        'td.content .contentText[data-side="' + side + '"]'
-      );
-      if (nextContent) return nextContent as HTMLElement;
-    }
-    return null;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
deleted file mode 100644
index ffc2dcd..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
-
-export class GrDiffBuilderUnified extends GrDiffBuilderLegacy {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  protected override getMoveControlsConfig() {
-    return {
-      numberOfCells: 3,
-      movedOutIndex: 2,
-      movedInIndex: 2,
-      lineNumberCols: [0, 1],
-    };
-  }
-
-  // visible for testing
-  override buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const sectionEl = createElementDiff('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (group.isTotal()) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      if (group.moveDetails.changed) {
-        sectionEl.classList.add('changed');
-      }
-      sectionEl.appendChild(this.buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this.createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
-      return sectionEl;
-    }
-
-    for (let i = 0; i < group.lines.length; ++i) {
-      const line = group.lines[i];
-      // If only whitespace has changed and the settings ask for whitespace to
-      // be ignored, only render the right-side line in unified diff mode.
-      if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
-        continue;
-      }
-      sectionEl.appendChild(this.createRow(line));
-    }
-    return sectionEl;
-  }
-
-  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = createElementDiff('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = createElementDiff('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add right-side line number.
-    col = createElementDiff('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add the content.
-    colgroup.appendChild(createElementDiff('col'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  protected createRow(line: GrDiffLine) {
-    const row = createElementDiff('tr', line.type);
-    row.classList.add('diff-row', 'unified');
-    // TabIndex makes screen reader read a row when navigating with j/k
-    row.tabIndex = -1;
-    row.appendChild(this.createBlameCell(line.beforeNumber));
-    let lineNumberEl = this.createLineEl(
-      line,
-      line.beforeNumber,
-      GrDiffLineType.REMOVE,
-      Side.LEFT
-    );
-    row.appendChild(lineNumberEl);
-    lineNumberEl = this.createLineEl(
-      line,
-      line.afterNumber,
-      GrDiffLineType.ADD,
-      Side.RIGHT
-    );
-    row.appendChild(lineNumberEl);
-    let side = undefined;
-    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
-      side = Side.RIGHT;
-    }
-    if (line.type === GrDiffLineType.REMOVE) {
-      side = Side.LEFT;
-    }
-
-    // Before Chrome 102, Chrome was able to compute a11y label from children
-    // content. Now Chrome 102 and Firefox are not computing a11y label because
-    // tr is not expected to have aria label. Adding aria role button is
-    // pushing browser to compute aria even for tr. This can be removed, once
-    // browsers will again compute a11y label even for tr when it is focused.
-    // TODO: Remove when Chrome 102 is out of date for 1 year.
-    row.setAttribute(
-      'aria-labelledby',
-      [
-        line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
-        side === Side.LEFT && line.beforeNumber
-          ? `left-content-${line.beforeNumber}`
-          : '',
-        line.afterNumber ? `right-button-${line.afterNumber}` : '',
-        side === Side.RIGHT && line.afterNumber
-          ? `right-content-${line.afterNumber}`
-          : '',
-      ]
-        .filter(id => !!id)
-        .join(' ')
-        .trim()
-    );
-    const twoSlots = line.type === GrDiffLineType.BOTH;
-    row.appendChild(this.createTextEl(lineNumberEl, line, side, twoSlots));
-    return row;
-  }
-
-  getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      // Note that this does not work when there is a "common" chunk in the
-      // diff (different content only because of whitespace). Such chunks are
-      // rendered with class "add", so these rows will be skipped for the
-      // 'left' side.
-      // TODO: Fix this when writing a Lit component for unified diff.
-      if (
-        tr.classList.contains('both') ||
-        (side === 'left' && tr.classList.contains('remove')) ||
-        (side === 'right' && tr.classList.contains('add'))
-      ) {
-        return tr.querySelector('.contentText');
-      }
-    }
-    return null;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
deleted file mode 100644
index 8c44727..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import '../gr-diff/gr-diff-group';
-import './gr-diff-builder';
-import './gr-diff-builder-unified';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {DiffPreferencesInfo} from '../../../api/diff';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {createDiff} from '../../../test/test-data-generators';
-import {queryAndAssert} from '../../../utils/common-util';
-import {assert} from '@open-wc/testing';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs: DiffPreferencesInfo;
-  let outputEl: HTMLElement;
-  let diffBuilder: GrDiffBuilderUnified;
-
-  setup(() => {
-    prefs = {
-      ...createDefaultDiffPrefs(),
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines: GrDiffLine[];
-    let group: GrDiffGroup;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
-        lines[0].beforeNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
-        lines[0].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[0], '.content').textContent,
-        lines[0].text
-      );
-
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
-        lines[1].beforeNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
-        lines[1].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[1], '.content').textContent,
-        lines[1].text
-      );
-
-      assert.equal(
-        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
-        lines[2].beforeNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
-        lines[2].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[2], '.content').textContent,
-        lines[2].text
-      );
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        moveDetails: {changed: false},
-      });
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        moveDetails: {changed: false},
-      });
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines: GrDiffLine[];
-    let group: GrDiffGroup;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-    });
-
-    test('creates the section', () => {
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        ignoredWhitespaceOnly: true,
-      });
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        dueToRebase: true,
-      });
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
-        lines[0].beforeNumber.toString()
-      );
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-        queryAndAssert(rowEls[0], '.content').textContent,
-        lines[0].text
-      );
-
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
-        lines[1].beforeNumber.toString()
-      );
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-        queryAndAssert(rowEls[1], '.content').textContent,
-        lines[1].text
-      );
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
-        lines[2].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[2], '.content').textContent,
-        lines[2].text
-      );
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
-        lines[3].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[3], '.content').textContent,
-        lines[3].text
-      );
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group = new GrDiffGroup({
-        type: GrDiffGroupType.DELTA,
-        lines,
-        ignoredWhitespaceOnly: true,
-      });
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
-        lines[2].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[0], '.content').textContent,
-        lines[2].text
-      );
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
-        lines[3].afterNumber.toString()
-      );
-      assert.equal(
-        queryAndAssert(rowEls[1], '.content').textContent,
-        lines[3].text
-      );
-    });
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index 5ca5197..f38ba5c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -3,19 +3,27 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
 import {
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
+  DiffViewMode,
   RenderPreferences,
 } from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {assert} from '../../../utils/common-util';
-import '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffSection} from './gr-diff-section';
+import {html, render} from 'lit';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {when} from 'lit/directives/when.js';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
 
 export interface DiffContextExpandedEventDetail
   extends DiffContextExpandedExternalDetail {
@@ -32,73 +40,34 @@
   }
 }
 
-/**
- * Given that GrDiffBuilder has ~1,000 lines of code, this interface is just
- * making refactorings easier by emphasizing what the public facing "contract"
- * of this class is. There are no plans for adding separate implementations.
- */
-export interface DiffBuilder {
-  init(): void;
-  cleanup(): void;
-  addGroups(groups: readonly GrDiffGroup[]): void;
-  clearGroups(): void;
-  replaceGroup(
-    contextControl: GrDiffGroup,
-    groups: readonly GrDiffGroup[]
-  ): void;
-  findGroup(side: Side, line: LineNumber): GrDiffGroup | undefined;
-  addColumns(outputEl: HTMLElement, fontSize: number): void;
-  // TODO: Change `null` to `undefined`.
-  getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: Element
-  ): HTMLTableCellElement | null;
-  getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | null;
-  getLineNumberRows(): HTMLTableRowElement[];
-  getLineNumEls(side: Side): HTMLTableCellElement[];
-  setBlame(blame: BlameInfo[]): void;
-  updateRenderPrefs(renderPrefs: RenderPreferences): void;
+export function isImageDiffBuilder<T extends GrDiffBuilder>(
+  x: T | GrDiffBuilderImage | undefined
+): x is GrDiffBuilderImage {
+  return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
 }
 
-export interface ImageDiffBuilder extends DiffBuilder {
-  renderImageDiff(): void;
-}
-
-export function isImageDiffBuilder(
-  x: DiffBuilder | ImageDiffBuilder | undefined
-): x is ImageDiffBuilder {
-  return !!x && !!(x as ImageDiffBuilder).renderImageDiff;
+export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
+  x: T | GrDiffBuilderBinary | undefined
+): x is GrDiffBuilderBinary {
+  return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
 }
 
 /**
- * Base class for different diff builders, like side-by-side, unified etc.
- *
  * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
  * called sections. Only the builder should add or remove sections from the
  * DOM. Callers can use the ...group() methods to modify groups and thus cause
  * rendering changes.
- *
- * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
  */
-export abstract class GrDiffBuilder implements DiffBuilder {
-  protected readonly _diff: DiffInfo;
+export class GrDiffBuilder {
+  private readonly diff: DiffInfo;
 
-  protected readonly numLinesLeft: number;
+  readonly prefs: DiffPreferencesInfo;
 
-  // visible for testing
-  readonly _prefs: DiffPreferencesInfo;
+  renderPrefs?: RenderPreferences;
 
-  protected renderPrefs?: RenderPreferences;
+  readonly outputEl: HTMLElement;
 
-  protected readonly outputEl: HTMLElement;
-
-  protected groups: GrDiffGroup[];
-
-  private blameInfo: BlameInfo[] = [];
+  private groups: GrDiffGroup[];
 
   private readonly layerUpdateListener: (
     start: LineNumber,
@@ -113,14 +82,8 @@
     readonly layers: DiffLayer[] = [],
     renderPrefs?: RenderPreferences
   ) {
-    this._diff = diff;
-    this.numLinesLeft = this._diff.content
-      ? this._diff.content.reduce((sum, chunk) => {
-          const left = chunk.a || chunk.ab;
-          return sum + (left?.length || chunk.skip || 0);
-        }, 0)
-      : 0;
-    this._prefs = prefs;
+    this.diff = diff;
+    this.prefs = prefs;
     this.renderPrefs = renderPrefs;
     this.outputEl = outputEl;
     this.groups = [];
@@ -141,6 +104,138 @@
     this.init();
   }
 
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(lineNumber, side);
+    return row?.getContentCell(side);
+  }
+
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(lineNumber, side);
+    return row?.getLineNumberCell(side);
+  }
+
+  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+    if (!side || !lineNumber) return undefined;
+    const group = this.findGroup(side, lineNumber);
+    if (!group) return undefined;
+    const section = this.findSection(group);
+    if (!section) return undefined;
+    return section.findRow(side, lineNumber);
+  }
+
+  private getDiffRows() {
+    const sections = [
+      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+    ];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  /** This is used when layers initiate an update. */
+  renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      const section = this.findSection(group);
+      for (const row of section?.getDiffRows() ?? []) {
+        row.requestUpdate();
+      }
+    }
+  }
+
+  private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    return (
+      this.outputEl.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const leftCl = `left-${group.startLine(Side.LEFT)}`;
+    const rightCl = `right-${group.startLine(Side.RIGHT)}`;
+    const section = html`
+      <gr-diff-section
+        class="${leftCl} ${rightCl}"
+        .group=${group}
+        .diff=${this.diff}
+        .layers=${this.layers}
+        .diffPrefs=${this.prefs}
+        .renderPrefs=${this.renderPrefs}
+      ></gr-diff-section>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+    // method into Lit's `render()` cycle.
+    const tempEl = document.createElement('div');
+    render(section, tempEl);
+    const sectionEl = tempEl.firstElementChild as GrDiffSection;
+    return sectionEl;
+  }
+
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = html`
+      <colgroup>
+        <col class=${diffClasses('blame')}></col>
+        ${when(
+          this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
+          () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
+          () => html`
+            ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+            ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+          `
+        )}
+      </colgroup>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+    // method into Lit's `render()` cycle.
+    const tempEl = document.createElement('div');
+    render(colgroup, tempEl);
+    const colgroupEl = tempEl.firstElementChild as HTMLElement;
+    outputEl.appendChild(colgroupEl);
+  }
+
+  private renderUnifiedColumns(lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()}></col>
+    `;
+  }
+
+  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+      <col class=${diffClasses(side, 'sign')}></col>
+      <col class=${diffClasses(side)}></col>
+    `;
+  }
+
   /**
    * This is meant to be called when the gr-diff component re-connects, or when
    * the diff is (re-)rendered.
@@ -172,10 +267,6 @@
     }
   }
 
-  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
-
-  protected abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
-
   addGroups(groups: readonly GrDiffGroup[]) {
     for (const group of groups) {
       this.groups.push(group);
@@ -238,151 +329,19 @@
       .filter(group => group.lines.length > 0);
   }
 
-  // TODO: Change `null` to `undefined`.
-  abstract getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: Element
-  ): HTMLTableCellElement | null;
-
-  // TODO: Change `null` to `undefined`.
-  abstract getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | null;
-
-  abstract getLineNumberRows(): HTMLTableRowElement[];
-
-  abstract getLineNumEls(side: Side): HTMLTableCellElement[];
-
-  protected abstract getBlameTdByLine(lineNum: number): Element | undefined;
-
-  // TODO: Change `null` to `undefined`.
-  protected abstract getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: HTMLElement
-  ): HTMLElement | null;
-
-  /**
-   * Find line elements or line objects by a range of line numbers and a side.
-   *
-   * @param start The first line number
-   * @param end The last line number
-   * @param side The side of the range. Either 'left' or 'right'.
-   * @param out_lines The output list of line objects.
-   *        TODO: Change to camelCase.
-   * @param out_elements The output list of line elements.
-   *        TODO: Change to camelCase.
-   */
-  // visible for testing
-  findLinesByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side,
-    out_lines: GrDiffLine[],
-    out_elements: HTMLElement[]
-  ) {
-    const groups = this.getGroupsByLineRange(start, end, side);
-    for (const group of groups) {
-      let content: HTMLElement | null = null;
-      for (const line of group.lines) {
-        if (
-          (side === 'left' && line.type === GrDiffLineType.ADD) ||
-          (side === 'right' && line.type === GrDiffLineType.REMOVE)
-        ) {
-          continue;
-        }
-        const lineNumber =
-          side === 'left' ? line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) {
-          continue;
-        }
-
-        if (content) {
-          content = this.getNextContentOnSide(content, side);
-        } else {
-          content = this.getContentByLine(lineNumber, side, group.element);
-        }
-        if (content) {
-          // out_lines and out_elements must match. So if we don't have an
-          // element to push, then also don't push a line.
-          out_lines.push(line);
-          out_elements.push(content);
-        }
-      }
-    }
-    assert(
-      out_lines.length === out_elements.length,
-      'findLinesByRange: lines and elements arrays must have same length'
-    );
-  }
-
-  protected abstract renderContentByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ): void;
-
-  protected abstract renderBlameByRange(
-    blame: BlameInfo,
-    start: number,
-    end: number
-  ): void;
-
-  /**
-   * Finds the next DIV.contentText element following the given element, and on
-   * the same side. Will only search within a group.
-   *
-   * TODO: Change `null` to `undefined`.
-   */
-  protected abstract getNextContentOnSide(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null;
-
-  /**
-   * Gets configuration for creating move controls for chunks marked with
-   * dueToMove
-   */
-  protected abstract getMoveControlsConfig(): {
-    numberOfCells: number;
-    movedOutIndex: number;
-    movedInIndex: number;
-    lineNumberCols: number[];
-    signCols?: {left: number; right: number};
-  };
-
   /**
    * Set the blame information for the diff. For any already-rendered line,
    * re-render its blame cell content.
    */
   setBlame(blame: BlameInfo[]) {
-    this.blameInfo = blame;
-    for (const commit of blame) {
-      for (const range of commit.ranges) {
-        this.renderBlameByRange(commit, range.start, range.end);
-      }
-    }
-  }
-
-  /**
-   * Given a base line number, return the commit containing that line in the
-   * current set of blame information. If no blame information has been
-   * provided, null is returned.
-   *
-   * @return The commit information.
-   */
-  // visible for testing
-  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
-    for (const blameCommit of this.blameInfo) {
-      for (const range of blameCommit.ranges) {
-        if (range.start <= lineNum && range.end >= lineNum) {
-          return blameCommit;
+    for (const blameInfo of blame) {
+      for (const range of blameInfo.ranges) {
+        for (let line = range.start; line <= range.end; line++) {
+          const row = this.findRow(line, Side.LEFT);
+          if (row) row.blameInfo = blameInfo;
         }
       }
     }
-    return undefined;
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 02f2233..69c0f5c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -44,7 +44,7 @@
  * fully blown dependency on GrDiffBuilderElement.
  */
 export interface DiffBuilderInterface {
-  getContentTdByLineEl(lineEl?: Element): Element | null;
+  getContentTdByLineEl(lineEl?: Element): Element | undefined;
 }
 
 /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 22a71a5..05e5d3b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -15,12 +15,10 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {RenderPreferences} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 
 const WHOLE_FILE = -1;
@@ -95,15 +93,17 @@
 
   keyLocations: KeyLocations = {left: {}, right: {}};
 
-  private asyncThreshold = 64;
-
-  private nextStepHandle: number | null = null;
-
-  private processPromise: CancelablePromise<void> | null = null;
+  asyncThreshold = 64;
 
   // visible for testing
   isScrolling?: boolean;
 
+  /** Just for making sure that process() is only called once. */
+  private isStarted = false;
+
+  /** Indicates that processing should be stopped. */
+  private isCancelled = false;
+
   private resetIsScrollingTask?: DelayedTask;
 
   private readonly handleWindowScroll = () => {
@@ -123,9 +123,9 @@
    * array of GrDiffGroups when the diff is completely processed.
    */
   process(chunks: DiffContent[], isBinary: boolean) {
-    // Cancel any still running process() calls, because they append to the
-    // same groups field.
-    this.cancel();
+    assert(this.isStarted === false, 'diff processor cannot be started twice');
+    this.isStarted = true;
+
     window.addEventListener('scroll', this.handleWindowScroll);
 
     assertIsDefined(this.consumer, 'consumer');
@@ -133,84 +133,61 @@
     this.consumer.addGroup(this.makeGroup('LOST'));
     this.consumer.addGroup(this.makeGroup(FILE));
 
-    // If it's a binary diff, we won't be rendering hunks of text differences
-    // so finish processing.
-    if (isBinary) {
-      return Promise.resolve();
-    }
+    if (isBinary) return Promise.resolve();
 
-    // TODO: Canceling this promise does not help much. `nextStep` will continue
-    // to be scheduled anyway. So either just remove the cancelable promise, so
-    // future programmers are not fooled about this promise can do. Or fix the
-    // scheduling of `nextStep` such that cancellation is taken into account.
-    // The easiest approach is likely to just not re-use the processor for
-    // multiple processing passes. There is no benefit from doing so.
-    this.processPromise = makeCancelable(
-      new Promise(resolve => {
-        const state = {
-          lineNums: {left: 0, right: 0},
-          chunkIndex: 0,
-        };
+    return new Promise<void>(resolve => {
+      const state = {
+        lineNums: {left: 0, right: 0},
+        chunkIndex: 0,
+      };
 
-        chunks = this.splitLargeChunks(chunks);
-        chunks = this.splitCommonChunksWithKeyLocations(chunks);
+      chunks = this.splitLargeChunks(chunks);
+      chunks = this.splitCommonChunksWithKeyLocations(chunks);
 
-        let currentBatch = 0;
-        const nextStep = () => {
-          if (this.isScrolling) {
-            this.nextStepHandle = window.setTimeout(nextStep, 100);
-            return;
-          }
-          // If we are done, resolve the promise.
-          if (state.chunkIndex >= chunks.length) {
-            resolve();
-            this.nextStepHandle = null;
-            return;
-          }
+      let currentBatch = 0;
+      const nextStep = () => {
+        if (this.isCancelled || state.chunkIndex >= chunks.length) {
+          resolve();
+          return;
+        }
+        if (this.isScrolling) {
+          window.setTimeout(nextStep, 100);
+          return;
+        }
 
-          // Process the next chunk and incorporate the result.
-          const stateUpdate = this.processNext(state, chunks);
-          for (const group of stateUpdate.groups) {
-            assertIsDefined(this.consumer, 'consumer');
-            this.consumer.addGroup(group);
-            currentBatch += group.lines.length;
-          }
-          state.lineNums.left += stateUpdate.lineDelta.left;
-          state.lineNums.right += stateUpdate.lineDelta.right;
+        const stateUpdate = this.processNext(state, chunks);
+        for (const group of stateUpdate.groups) {
+          this.consumer?.addGroup(group);
+          currentBatch += group.lines.length;
+        }
+        state.lineNums.left += stateUpdate.lineDelta.left;
+        state.lineNums.right += stateUpdate.lineDelta.right;
 
-          // Increment the index and recurse.
-          state.chunkIndex = stateUpdate.newChunkIndex;
-          if (currentBatch >= this.asyncThreshold) {
-            currentBatch = 0;
-            this.nextStepHandle = window.setTimeout(nextStep, 1);
-          } else {
-            nextStep.call(this);
-          }
-        };
+        state.chunkIndex = stateUpdate.newChunkIndex;
+        if (currentBatch >= this.asyncThreshold) {
+          currentBatch = 0;
+          window.setTimeout(nextStep, 1);
+        } else {
+          nextStep.call(this);
+        }
+      };
 
-        nextStep.call(this);
-      })
-    );
-    return this.processPromise.finally(() => {
-      this.processPromise = null;
-      window.removeEventListener('scroll', this.handleWindowScroll);
+      nextStep.call(this);
+    }).finally(() => {
+      this.finish();
     });
   }
 
-  /**
-   * Cancel any jobs that are running.
-   */
-  cancel() {
-    if (this.nextStepHandle !== null) {
-      window.clearTimeout(this.nextStepHandle);
-      this.nextStepHandle = null;
-    }
-    if (this.processPromise) {
-      this.processPromise.cancel();
-    }
+  finish() {
+    this.consumer = undefined;
     window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
+  cancel() {
+    this.isCancelled = true;
+    this.finish();
+  }
+
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
@@ -739,10 +716,4 @@
 
     return this.breakdown(head, size).concat([tail]);
   }
-
-  updateRenderPrefs(renderPrefs: RenderPreferences) {
-    if (renderPrefs.num_lines_rendered_at_once) {
-      this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
-    }
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 5b23ee7..adcfff8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -784,13 +784,16 @@
       ]);
     });
 
-    test('scrolling pauses rendering', () => {
+    test('isScrolling paused', () => {
       const content = Array(200).fill({ab: ['', '']});
       element.isScrolling = true;
       element.process(content, false);
-      // Just the files group - no more processing during scrolling.
+      // Just the FILE and LOST groups.
       assert.equal(groups.length, 2);
+    });
 
+    test('isScrolling unpaused', () => {
+      const content = Array(200).fill({ab: ['', '']});
       element.isScrolling = false;
       element.process(content, false);
       // More groups have been processed. How many does not matter here.
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 5a1ded2..2ec0a2e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -441,11 +441,17 @@
   }
 
   startLine(side: Side): LineNumber {
-    if (this.type === GrDiffGroupType.CONTEXT_CONTROL) {
+    // For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
+    // be empty. So we have to use `lineRange` instead of looking at the first
+    // line.
+    if (this.type === GrDiffGroupType.CONTEXT_CONTROL || this.skip) {
       return side === Side.LEFT
         ? this.lineRange.left.start_line
         : this.lineRange.right.start_line;
     }
+    // For "normal" groups we could also use the `lineRange`, but for FILE or
+    // LOST lines we want to return FILE or LOST. The `lineRange` contains
+    // numbers only.
     return this.lines[0].lineNumber(side);
   }
 
@@ -500,16 +506,8 @@
       return Promise.resolve();
     }
     assertIsDefined(this.element);
-    // This is a temporary hack while migration to lit based diff rendering:
-    // Elements with 'display: contents;' do not have a height, so they
-    // won't work as intended with `untilRendered()`.
-    const isLitDiff = this.element.tagName === 'GR-DIFF-SECTION';
-    if (isLitDiff) {
-      await (this.element as LitElement).updateComplete;
-      await untilRendered(this.element.firstElementChild as HTMLElement);
-    } else {
-      await untilRendered(this.element);
-    }
+    await (this.element as LitElement).updateComplete;
+    await untilRendered(this.element.firstElementChild as HTMLElement);
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 4a19282..06bf92920 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -275,6 +275,17 @@
       assert.equal(group.startLine(Side.RIGHT), 4);
     });
 
+    test('SKIP', () => {
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        skip: 10,
+        offsetLeft: 3,
+        offsetRight: 6,
+      });
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 6);
+    });
+
     test('FILE', () => {
       const lines: GrDiffLine[] = [];
       lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
new file mode 100644
index 0000000..e7f4b51
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const grDiffStyles = css`
+  /* This is used to hide all left side of the diff (e.g. diffs besides
+     comments in the change log). Since we want to remove the first 4
+     cells consistently in all rows except context buttons (.dividerRow). */
+  :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+  :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+    display: none;
+  }
+  :host(.disable-context-control-buttons) {
+    --context-control-display: none;
+  }
+  :host(.disable-context-control-buttons) .section {
+    border-right: none;
+  }
+  :host(.hide-line-length-indicator) .full-width td.content .contentText {
+    background-image: none;
+  }
+
+  :host {
+    font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+    font-size: var(--font-size, var(--font-size-code, 12px));
+    /* usually 16px = 12px + 4px */
+    line-height: calc(
+      var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+    );
+  }
+
+  .thread-group {
+    display: block;
+    max-width: var(--content-width, 80ch);
+    white-space: normal;
+    background-color: var(--diff-blank-background-color);
+  }
+  .diffContainer {
+    max-width: var(--diff-max-width, none);
+    font-family: var(--monospace-font-family);
+  }
+  table {
+    border-collapse: collapse;
+    table-layout: fixed;
+  }
+  td.lineNum {
+    /* Enforces background whenever lines wrap */
+    background-color: var(--diff-blank-background-color);
+  }
+
+  /* Provides the option to add side borders (left and right) to the line
+     number column. */
+  td.lineNum,
+  td.blankLineNum,
+  td.moveControlsLineNumCol,
+  td.contextLineNum {
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+
+  /* Context controls break up the table visually, so we set the right
+     border on individual sections to leave a gap for the divider.
+
+     Also taken into account for max-width calculations in SHRINK_ONLY mode
+     (check GrDiff.updatePreferenceStyles). */
+  .section {
+    border-right: 1px solid var(--border-color);
+  }
+  .section.contextControl {
+    /* Divider inside this section must not have border; we set borders on
+       the padding rows below. */
+    border-right-width: 0;
+  }
+  /* Padding rows behind context controls. The diff is styled to be cut
+     into two halves by the negative space of the divider on which the
+     context control buttons are anchored. */
+  .contextBackground {
+    border-right: 1px solid var(--border-color);
+  }
+  .contextBackground.above {
+    border-bottom: 1px solid var(--border-color);
+  }
+  .contextBackground.below {
+    border-top: 1px solid var(--border-color);
+  }
+
+  .lineNumButton {
+    display: block;
+    width: 100%;
+    height: 100%;
+    background-color: var(--diff-blank-background-color);
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+  td.lineNum {
+    vertical-align: top;
+  }
+
+  /* The only way to focus this (clicking) will apply our own focus
+     styling, so this default styling is not needed and distracting. */
+  .lineNumButton:focus {
+    outline: none;
+  }
+  gr-image-viewer {
+    width: 100%;
+    height: 100%;
+    max-width: var(--image-viewer-max-width, 95vw);
+    max-height: var(--image-viewer-max-height, 90vh);
+    /* Defined by paper-styles default-theme and used in various
+       components. background-color-secondary is a compromise between
+       fairly light in light theme (where we ideally would want
+       background-color-primary) yet slightly offset against the app
+       background in dark mode, where drop shadows e.g. around paper-card
+       are almost invisible. */
+    --primary-background-color: var(--background-color-secondary);
+  }
+  .image-diff .gr-diff {
+    text-align: center;
+  }
+  .image-diff img {
+    box-shadow: var(--elevation-level-1);
+    max-width: 50em;
+  }
+  .image-diff .right.lineNumButton {
+    border-left: 1px solid var(--border-color);
+  }
+  .image-diff label {
+    font-family: var(--font-family);
+    font-style: italic;
+  }
+  tbody.binary-diff td {
+    font-family: var(--font-family);
+    font-style: italic;
+    text-align: center;
+    padding: var(--spacing-s) 0;
+  }
+  .diff-row {
+    outline: none;
+    user-select: none;
+  }
+  .diff-row.target-row.target-side-left .lineNumButton.left,
+  .diff-row.target-row.target-side-right .lineNumButton.right,
+  .diff-row.target-row.unified .lineNumButton {
+    color: var(--primary-text-color);
+  }
+
+  /* Preparing selected line cells with position relative so it allows a
+     positioned overlay with 'position: absolute'. */
+  .target-row td {
+    position: relative;
+  }
+
+  /* Defines an overlay to the selected line for drawing an outline without
+     blocking user interaction (e.g. text selection). */
+  .target-row td::before {
+    border-width: 0;
+    border-style: solid;
+    border-color: var(--focused-line-outline-color);
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+    user-select: none;
+    content: ' ';
+  }
+
+  /* The outline for the selected content cell should be the same in all
+     cases. */
+  .target-row.target-side-left td.left.content::before,
+  .target-row.target-side-right td.right.content::before,
+  .unified.target-row td.content::before {
+    border-width: 1px 1px 1px 0;
+  }
+
+  /* The outline for the sign cell should be always be contiguous
+     top/bottom. */
+  .target-row.target-side-left td.left.sign::before,
+  .target-row.target-side-right td.right.sign::before {
+    border-width: 1px 0;
+  }
+
+  /* For side-by-side we need to select the correct line number to
+     "visually close" the outline. */
+  .side-by-side.target-row.target-side-left td.left.lineNum::before,
+  .side-by-side.target-row.target-side-right td.right.lineNum::before {
+    border-width: 1px 0 1px 1px;
+  }
+
+  /* For unified diff we always start the overlay from the left cell. */
+  .unified.target-row td.left:not(.content)::before {
+    border-width: 1px 0 1px 1px;
+  }
+
+  /* For unified diff we should continue the top/bottom border in right
+     line number column. */
+  .unified.target-row td.right:not(.content)::before {
+    border-width: 1px 0;
+  }
+
+  .content {
+    background-color: var(--diff-blank-background-color);
+  }
+
+  /* Describes two states of semantic tokens: whenever a token has a
+     definition that can be navigated to (navigable) and whenever
+     the token is actually clickable to perform this navigation. */
+  .semantic-token.navigable {
+    text-decoration-style: dotted;
+    text-decoration-line: underline;
+  }
+  .semantic-token.navigable.clickable {
+    text-decoration-style: solid;
+    cursor: pointer;
+  }
+
+  /* The file line, which has no contentText, add some margin before the
+     first comment. We cannot add padding the container because we only
+     want it if there is at least one comment thread, and the slotting
+     makes :empty not work as expected. */
+  .content.file slot:first-child::slotted(.comment-thread) {
+    display: block;
+    margin-top: var(--spacing-xs);
+  }
+  .contentText {
+    background-color: var(--view-background-color);
+  }
+  .blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  .image-diff .content {
+    background-color: var(--diff-blank-background-color);
+  }
+  .responsive {
+    width: 100%;
+  }
+  .responsive .contentText {
+    white-space: break-spaces;
+    word-break: break-all;
+  }
+  .lineNumButton,
+  .content {
+    vertical-align: top;
+    white-space: pre;
+  }
+  .contextLineNum,
+  .lineNumButton {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+
+    color: var(--deemphasized-text-color);
+    padding: 0 var(--spacing-m);
+    text-align: right;
+  }
+  .canComment .lineNumButton {
+    cursor: pointer;
+  }
+  .sign {
+    min-width: 1ch;
+    width: 1ch;
+    background-color: var(--view-background-color);
+  }
+  .sign.blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  .content {
+    /* Set min width since setting width on table cells still allows them
+       to shrink. Do not set max width because CJK
+       (Chinese-Japanese-Korean) glyphs have variable width. */
+    min-width: var(--content-width, 80ch);
+    width: var(--content-width, 80ch);
+  }
+  /* If there are no intraline info, consider everything changed */
+  .content.add .contentText .intraline,
+  .content.add.no-intraline-info .contentText,
+  .sign.add.no-intraline-info,
+  .delta.total .content.add .contentText {
+    background-color: var(--dark-add-highlight-color);
+  }
+  .content.add .contentText,
+  .sign.add {
+    background-color: var(--light-add-highlight-color);
+  }
+  /* If there are no intraline info, consider everything changed */
+  .content.remove .contentText .intraline,
+  .content.remove.no-intraline-info .contentText,
+  .delta.total .content.remove .contentText,
+  .sign.remove.no-intraline-info {
+    background-color: var(--dark-remove-highlight-color);
+  }
+  .content.remove .contentText,
+  .sign.remove {
+    background-color: var(--light-remove-highlight-color);
+  }
+
+  .ignoredWhitespaceOnly .sign.no-intraline-info {
+    background-color: var(--view-background-color);
+  }
+
+  /* dueToRebase */
+  .dueToRebase .content.add .contentText .intraline,
+  .delta.total.dueToRebase .content.add .contentText {
+    background-color: var(--dark-rebased-add-highlight-color);
+  }
+  .dueToRebase .content.add .contentText {
+    background-color: var(--light-rebased-add-highlight-color);
+  }
+  .dueToRebase .content.remove .contentText .intraline,
+  .delta.total.dueToRebase .content.remove .contentText {
+    background-color: var(--dark-rebased-remove-highlight-color);
+  }
+  .dueToRebase .content.remove .contentText {
+    background-color: var(--light-rebased-remove-highlight-color);
+  }
+
+  /* dueToMove */
+  .dueToMove .sign.add,
+  .dueToMove .content.add .contentText,
+  .dueToMove .moveControls.movedIn .sign.right,
+  .dueToMove .moveControls.movedIn .moveHeader,
+  .delta.total.dueToMove .content.add .contentText {
+    background-color: var(--diff-moved-in-background);
+  }
+
+  .dueToMove.changed .sign.add,
+  .dueToMove.changed .content.add .contentText,
+  .dueToMove.changed .moveControls.movedIn .sign.right,
+  .dueToMove.changed .moveControls.movedIn .moveHeader,
+  .delta.total.dueToMove.changed .content.add .contentText {
+    background-color: var(--diff-moved-in-changed-background);
+  }
+
+  .dueToMove .sign.remove,
+  .dueToMove .content.remove .contentText,
+  .dueToMove .moveControls.movedOut .moveHeader,
+  .dueToMove .moveControls.movedOut .sign.left,
+  .delta.total.dueToMove .content.remove .contentText {
+    background-color: var(--diff-moved-out-background);
+  }
+
+  .delta.dueToMove .movedIn .moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-label-color);
+  }
+  .delta.dueToMove.changed .movedIn .moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+  }
+  .delta.dueToMove .movedOut .moveHeader {
+    --gr-range-header-color: var(--diff-moved-out-label-color);
+  }
+
+  .moveHeader a {
+    color: inherit;
+  }
+
+  /* ignoredWhitespaceOnly */
+  .ignoredWhitespaceOnly .content.add .contentText .intraline,
+  .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+  .ignoredWhitespaceOnly .content.add .contentText,
+  .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+  .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+  .ignoredWhitespaceOnly .content.remove .contentText {
+    background-color: var(--view-background-color);
+  }
+
+  .content .contentText gr-diff-text:empty:after,
+  .content .contentText gr-legacy-text:empty:after,
+  .content .contentText:empty:after {
+    /* Newline, to ensure empty lines are one line-height tall. */
+    content: '\\A';
+  }
+
+  /* Context controls */
+  .contextControl {
+    display: var(--context-control-display, table-row-group);
+    background-color: transparent;
+    border: none;
+    --divider-height: var(--spacing-s);
+    --divider-border: 1px;
+  }
+  /* TODO: Is this still used? */
+  .contextControl gr-button gr-icon {
+    /* should match line-height of gr-button */
+    font-size: var(--line-height-mono, 18px);
+  }
+  .contextControl td:not(.lineNumButton) {
+    text-align: center;
+  }
+
+  /* Padding rows behind context controls. Styled as a continuation of the
+     line gutters and code area. */
+  .contextBackground > .contextLineNum {
+    background-color: var(--diff-blank-background-color);
+  }
+  .contextBackground > td:not(.contextLineNum) {
+    background-color: var(--view-background-color);
+  }
+  .contextBackground {
+    /* One line of background behind the context expanders which they can
+       render on top of, plus some padding. */
+    height: calc(var(--line-height-normal) + var(--spacing-s));
+  }
+
+  .dividerCell {
+    vertical-align: top;
+  }
+  .dividerRow.show-both .dividerCell {
+    height: var(--divider-height);
+  }
+  .dividerRow.show-above .dividerCell,
+  .dividerRow.show-above .dividerCell {
+    height: 0;
+  }
+
+  .br:after {
+    /* Line feed */
+    content: '\\A';
+  }
+  .tab {
+    display: inline-block;
+  }
+  .tab-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    /* >> character */
+    content: '\\00BB';
+    position: absolute;
+  }
+  .special-char-indicator {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  .special-char-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    content: '•';
+    position: absolute;
+  }
+  .special-char-warning {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  .special-char-warning:before {
+    color: var(--warning-foreground);
+    content: '!';
+    position: absolute;
+  }
+  /* Is defined after other background-colors, such that this
+     rule wins in case of same specificity. */
+  .trailing-whitespace,
+  .content .contentText .trailing-whitespace,
+  .trailing-whitespace .intraline,
+  .content .contentText .trailing-whitespace .intraline {
+    border-radius: var(--border-radius, 4px);
+    background-color: var(--diff-trailing-whitespace-indicator);
+  }
+  #diffHeader {
+    background-color: var(--table-header-background-color);
+    border-bottom: 1px solid var(--border-color);
+    color: var(--link-color);
+    padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+  }
+  #diffTable {
+    /* for gr-selection-action-box positioning */
+    position: relative;
+  }
+  #diffTable:focus {
+    outline: none;
+  }
+  #loadingError,
+  #sizeWarning {
+    display: block;
+    margin: var(--spacing-l) auto;
+    max-width: 60em;
+    text-align: center;
+  }
+  #loadingError {
+    color: var(--error-text-color);
+  }
+  #sizeWarning gr-button {
+    margin: var(--spacing-l);
+  }
+  .target-row td.blame {
+    background: var(--diff-selection-background-color);
+  }
+  td.lost div {
+    background-color: var(--info-background);
+  }
+  td.lost div.lost-message {
+    font-family: var(--font-family, 'Roboto');
+    font-size: var(--font-size-normal, 14px);
+    line-height: var(--line-height-normal);
+    padding: var(--spacing-s) 0;
+  }
+  td.lost div.lost-message gr-icon {
+    padding: 0 var(--spacing-s) 0 var(--spacing-m);
+    color: var(--blue-700);
+  }
+
+  col.sign,
+  td.sign {
+    display: none;
+  }
+
+  /* Sign column should only be shown in high-contrast mode. */
+  :host(.with-sign-col) col.sign {
+    display: table-column;
+  }
+  :host(.with-sign-col) td.sign {
+    display: table-cell;
+  }
+  col.blame {
+    display: none;
+  }
+  td.blame {
+    display: none;
+    padding: 0 var(--spacing-m);
+    white-space: pre;
+  }
+  :host(.showBlame) col.blame {
+    display: table-column;
+  }
+  :host(.showBlame) td.blame {
+    display: table-cell;
+  }
+  td.blame > span {
+    opacity: 0.6;
+  }
+  td.blame > span.startOfRange {
+    opacity: 1;
+  }
+  td.blame .blameDate {
+    font-family: var(--monospace-font-family);
+    color: var(--link-color);
+    text-decoration: none;
+  }
+  .responsive td.blame {
+    overflow: hidden;
+    width: 200px;
+  }
+  /** Support the line length indicator **/
+  .responsive td.content .contentText {
+    /* Same strategy as in
+       https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+       */
+    background-image: linear-gradient(
+      var(--line-length-indicator-color),
+      var(--line-length-indicator-color)
+    );
+    background-size: 1px 100%;
+    background-position: var(--line-limit-marker) 0;
+    background-repeat: no-repeat;
+  }
+  .newlineWarning {
+    color: var(--deemphasized-text-color);
+    text-align: center;
+  }
+  .newlineWarning.hidden {
+    display: none;
+  }
+  .lineNum.COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-covered, #e0f2f1);
+  }
+  .lineNum.NOT_COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-not-covered, #ffd1a4);
+  }
+  .lineNum.PARTIALLY_COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background: linear-gradient(
+      to right bottom,
+      var(--coverage-not-covered, #ffd1a4) 0%,
+      var(--coverage-not-covered, #ffd1a4) 50%,
+      var(--coverage-covered, #e0f2f1) 50%,
+      var(--coverage-covered, #e0f2f1) 100%
+    );
+  }
+
+  // TODO: Investigate whether this CSS is still necessary.
+  /* BEGIN: Select and copy for Polymer 2 */
+  /* Below was copied and modified from the original css in gr-diff-selection.html. */
+  .content,
+  .contextControl,
+  .blame {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+  }
+
+  .selected-left:not(.selected-comment)
+    .side-by-side
+    .left
+    + .content
+    .contentText,
+  .selected-right:not(.selected-comment)
+    .side-by-side
+    .right
+    + .content
+    .contentText,
+  .selected-left:not(.selected-comment)
+    .unified
+    .left.lineNum
+    ~ .content:not(.both)
+    .contentText,
+  .selected-right:not(.selected-comment)
+    .unified
+    .right.lineNum
+    ~ .content
+    .contentText,
+  .selected-left.selected-comment .side-by-side .left + .content .message,
+  .selected-right.selected-comment
+    .side-by-side
+    .right
+    + .content
+    .message
+    :not(.collapsedContent),
+  .selected-comment .unified .message :not(.collapsedContent),
+  .selected-blame .blame {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+
+  /* Make comments and check results selectable when selected */
+  .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
+  .selected-right.selected-comment
+    ::slotted(.comment-thread[diff-side='right']) {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+  /* END: Select and copy for Polymer 2 */
+
+  .whitespace-change-only-message {
+    background-color: var(--diff-context-control-background-color);
+    border: 1px solid var(--diff-context-control-border-color);
+    text-align: center;
+  }
+
+  .token-highlight {
+    background-color: var(--token-highlighting-color, #fffd54);
+  }
+
+  gr-selection-action-box {
+    /* Needs z-index to appear above wrapped content, since it's inserted
+       into DOM before it. */
+    z-index: 10;
+  }
+
+  gr-diff-image-new,
+  gr-diff-image-old,
+  gr-diff-section,
+  gr-context-controls-section,
+  gr-diff-row {
+    display: contents;
+  }
+`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 1c5d594..3929330 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -65,7 +65,7 @@
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {html, LitElement, nothing, PropertyValues} from 'lit';
 import {when} from 'lit/directives/when.js';
 import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
 import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
@@ -74,6 +74,7 @@
 import {expandFileMode} from '../../../utils/file-util';
 import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import {provide} from '../../../models/dependency';
+import {grDiffStyles} from './gr-diff-styles';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -278,713 +279,7 @@
       sharedStyles,
       grSyntaxTheme,
       grRangedCommentTheme,
-      css`
-        /**
-          This is used to hide all left side of the diff (e.g. diffs besides
-          comments in the change log). Since we want to remove the first 4
-          cells consistently in all rows except context buttons (.dividerRow).
-        */
-        :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
-        :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
-          display: none;
-        }
-        :host(.disable-context-control-buttons) {
-          --context-control-display: none;
-        }
-        :host(.disable-context-control-buttons) .section {
-          border-right: none;
-        }
-        :host(.hide-line-length-indicator) .full-width td.content .contentText {
-          background-image: none;
-        }
-
-        :host {
-          font-family: var(--monospace-font-family, ''), 'Roboto Mono';
-          font-size: var(--font-size, var(--font-size-code, 12px));
-          /* usually 16px = 12px + 4px */
-          line-height: calc(
-            var(--font-size, var(--font-size-code, 12px)) +
-              var(--spacing-s, 4px)
-          );
-        }
-
-        .thread-group {
-          display: block;
-          max-width: var(--content-width, 80ch);
-          white-space: normal;
-          background-color: var(--diff-blank-background-color);
-        }
-        .diffContainer {
-          max-width: var(--diff-max-width, none);
-          font-family: var(--monospace-font-family);
-        }
-        table {
-          border-collapse: collapse;
-          table-layout: fixed;
-        }
-        td.lineNum {
-          /* Enforces background whenever lines wrap */
-          background-color: var(--diff-blank-background-color);
-        }
-
-        /**
-          Provides the option to add side borders (left and right) to the line
-          number column.
-        */
-        td.lineNum,
-        td.blankLineNum,
-        td.moveControlsLineNumCol,
-        td.contextLineNum {
-          box-shadow: var(--line-number-box-shadow, unset);
-        }
-
-        /**
-          Context controls break up the table visually, so we set the right
-          border on individual sections to leave a gap for the divider.
-
-          Also taken into account for max-width calculations in SHRINK_ONLY mode
-          (check GrDiff.updatePreferenceStyles).
-        */
-        .section {
-          border-right: 1px solid var(--border-color);
-        }
-        .section.contextControl {
-          /**
-            Divider inside this section must not have border; we set borders on
-            the padding rows below.
-          */
-          border-right-width: 0;
-        }
-        /**
-          Padding rows behind context controls. The diff is styled to be cut
-          into two halves by the negative space of the divider on which the
-          context control buttons are anchored.
-        */
-        .contextBackground {
-          border-right: 1px solid var(--border-color);
-        }
-        .contextBackground.above {
-          border-bottom: 1px solid var(--border-color);
-        }
-        .contextBackground.below {
-          border-top: 1px solid var(--border-color);
-        }
-
-        .lineNumButton {
-          display: block;
-          width: 100%;
-          height: 100%;
-          background-color: var(--diff-blank-background-color);
-          box-shadow: var(--line-number-box-shadow, unset);
-        }
-        td.lineNum {
-          vertical-align: top;
-        }
-
-        /**
-          The only way to focus this (clicking) will apply our own focus
-          styling, so this default styling is not needed and distracting.
-        */
-        .lineNumButton:focus {
-          outline: none;
-        }
-        gr-image-viewer {
-          width: 100%;
-          height: 100%;
-          max-width: var(--image-viewer-max-width, 95vw);
-          max-height: var(--image-viewer-max-height, 90vh);
-          /**
-            Defined by paper-styles default-theme and used in various
-            components. background-color-secondary is a compromise between
-            fairly light in light theme (where we ideally would want
-            background-color-primary) yet slightly offset against the app
-            background in dark mode, where drop shadows e.g. around paper-card
-            are almost invisible.
-          */
-          --primary-background-color: var(--background-color-secondary);
-        }
-        .image-diff .gr-diff {
-          text-align: center;
-        }
-        .image-diff img {
-          box-shadow: var(--elevation-level-1);
-          max-width: 50em;
-        }
-        .image-diff .right.lineNumButton {
-          border-left: 1px solid var(--border-color);
-        }
-        .image-diff label,
-        .binary-diff label {
-          font-family: var(--font-family);
-          font-style: italic;
-        }
-        .diff-row {
-          outline: none;
-          user-select: none;
-        }
-        .diff-row.target-row.target-side-left .lineNumButton.left,
-        .diff-row.target-row.target-side-right .lineNumButton.right,
-        .diff-row.target-row.unified .lineNumButton {
-          color: var(--primary-text-color);
-        }
-
-        /**
-          Preparing selected line cells with position relative so it allows a
-          positioned overlay with 'position: absolute'.
-        */
-        .target-row td {
-          position: relative;
-        }
-
-        /**
-          Defines an overlay to the selected line for drawing an outline without
-          blocking user interaction (e.g. text selection).
-        */
-        .target-row td::before {
-          border-width: 0;
-          border-style: solid;
-          border-color: var(--focused-line-outline-color);
-          position: absolute;
-          top: 0;
-          left: 0;
-          width: 100%;
-          height: 100%;
-          pointer-events: none;
-          user-select: none;
-          content: ' ';
-        }
-
-        /**
-          the outline for the selected content cell should be the same in all
-          cases.
-        */
-        .target-row.target-side-left td.left.content::before,
-        .target-row.target-side-right td.right.content::before,
-        .unified.target-row td.content::before {
-          border-width: 1px 1px 1px 0;
-        }
-
-        /**
-          the outline for the sign cell should be always be contiguous
-          top/bottom.
-        */
-        .target-row.target-side-left td.left.sign::before,
-        .target-row.target-side-right td.right.sign::before {
-          border-width: 1px 0;
-        }
-
-        /**
-          For side-by-side we need to select the correct line number to
-          "visually close" the outline.
-        */
-        .side-by-side.target-row.target-side-left td.left.lineNum::before,
-        .side-by-side.target-row.target-side-right td.right.lineNum::before {
-          border-width: 1px 0 1px 1px;
-        }
-
-        /**
-          For unified diff we always start the overlay from the left cell
-        */
-        .unified.target-row td.left:not(.content)::before {
-          border-width: 1px 0 1px 1px;
-        }
-
-        /**
-          For unified diff we should continue the top/bottom border in right
-          line number column.
-        */
-        .unified.target-row td.right:not(.content)::before {
-          border-width: 1px 0;
-        }
-
-        .content {
-          background-color: var(--diff-blank-background-color);
-        }
-
-        /**
-          Describes two states of semantic tokens: whenever a token has a
-          definition that can be navigated to (navigable) and whenever
-          the token is actually clickable to perform this navigation.
-        */
-        .semantic-token.navigable {
-          text-decoration-style: dotted;
-          text-decoration-line: underline;
-        }
-        .semantic-token.navigable.clickable {
-          text-decoration-style: solid;
-          cursor: pointer;
-        }
-
-        /*
-          The file line, which has no contentText, add some margin before the
-          first comment. We cannot add padding the container because we only
-          want it if there is at least one comment thread, and the slotting
-          makes :empty not work as expected.
-        */
-        .content.file slot:first-child::slotted(.comment-thread) {
-          display: block;
-          margin-top: var(--spacing-xs);
-        }
-        .contentText {
-          background-color: var(--view-background-color);
-        }
-        .blank {
-          background-color: var(--diff-blank-background-color);
-        }
-        .image-diff .content {
-          background-color: var(--diff-blank-background-color);
-        }
-        .responsive {
-          width: 100%;
-        }
-        .responsive .contentText {
-          white-space: break-spaces;
-          word-break: break-all;
-        }
-        .lineNumButton,
-        .content {
-          vertical-align: top;
-          white-space: pre;
-        }
-        .contextLineNum,
-        .lineNumButton {
-          -webkit-user-select: none;
-          -moz-user-select: none;
-          -ms-user-select: none;
-          user-select: none;
-
-          color: var(--deemphasized-text-color);
-          padding: 0 var(--spacing-m);
-          text-align: right;
-        }
-        .canComment .lineNumButton {
-          cursor: pointer;
-        }
-        .sign {
-          min-width: 1ch;
-          width: 1ch;
-          background-color: var(--view-background-color);
-        }
-        .sign.blank {
-          background-color: var(--diff-blank-background-color);
-        }
-        .content {
-          /*
-            Set min width since setting width on table cells still allows them
-            to shrink. Do not set max width because CJK
-            (Chinese-Japanese-Korean) glyphs have variable width
-          */
-          min-width: var(--content-width, 80ch);
-          width: var(--content-width, 80ch);
-        }
-        .content.add .contentText .intraline,
-          /* If there are no intraline info, consider everything changed */
-          .content.add.no-intraline-info .contentText,
-          .sign.add.no-intraline-info,
-          .delta.total .content.add .contentText {
-          background-color: var(--dark-add-highlight-color);
-        }
-        .content.add .contentText,
-        .sign.add {
-          background-color: var(--light-add-highlight-color);
-        }
-        .content.remove .contentText .intraline,
-          /* If there are no intraline info, consider everything changed */
-          .content.remove.no-intraline-info .contentText,
-          .delta.total .content.remove .contentText,
-          .sign.remove.no-intraline-info {
-          background-color: var(--dark-remove-highlight-color);
-        }
-        .content.remove .contentText,
-        .sign.remove {
-          background-color: var(--light-remove-highlight-color);
-        }
-
-        .ignoredWhitespaceOnly .sign.no-intraline-info {
-          background-color: var(--view-background-color);
-        }
-
-        /* dueToRebase */
-        .dueToRebase .content.add .contentText .intraline,
-        .delta.total.dueToRebase .content.add .contentText {
-          background-color: var(--dark-rebased-add-highlight-color);
-        }
-        .dueToRebase .content.add .contentText {
-          background-color: var(--light-rebased-add-highlight-color);
-        }
-        .dueToRebase .content.remove .contentText .intraline,
-        .delta.total.dueToRebase .content.remove .contentText {
-          background-color: var(--dark-rebased-remove-highlight-color);
-        }
-        .dueToRebase .content.remove .contentText {
-          background-color: var(--light-rebased-remove-highlight-color);
-        }
-
-        /* dueToMove */
-        .dueToMove .sign.add,
-        .dueToMove .content.add .contentText,
-        .dueToMove .moveControls.movedIn .sign.right,
-        .dueToMove .moveControls.movedIn .moveHeader,
-        .delta.total.dueToMove .content.add .contentText {
-          background-color: var(--diff-moved-in-background);
-        }
-
-        .dueToMove.changed .sign.add,
-        .dueToMove.changed .content.add .contentText,
-        .dueToMove.changed .moveControls.movedIn .sign.right,
-        .dueToMove.changed .moveControls.movedIn .moveHeader,
-        .delta.total.dueToMove.changed .content.add .contentText {
-          background-color: var(--diff-moved-in-changed-background);
-        }
-
-        .dueToMove .sign.remove,
-        .dueToMove .content.remove .contentText,
-        .dueToMove .moveControls.movedOut .moveHeader,
-        .dueToMove .moveControls.movedOut .sign.left,
-        .delta.total.dueToMove .content.remove .contentText {
-          background-color: var(--diff-moved-out-background);
-        }
-
-        .delta.dueToMove .movedIn .moveHeader {
-          --gr-range-header-color: var(--diff-moved-in-label-color);
-        }
-        .delta.dueToMove.changed .movedIn .moveHeader {
-          --gr-range-header-color: var(--diff-moved-in-changed-label-color);
-        }
-        .delta.dueToMove .movedOut .moveHeader {
-          --gr-range-header-color: var(--diff-moved-out-label-color);
-        }
-
-        .moveHeader a {
-          color: inherit;
-        }
-
-        /* ignoredWhitespaceOnly */
-        .ignoredWhitespaceOnly .content.add .contentText .intraline,
-        .delta.total.ignoredWhitespaceOnly .content.add .contentText,
-        .ignoredWhitespaceOnly .content.add .contentText,
-        .ignoredWhitespaceOnly .content.remove .contentText .intraline,
-        .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
-        .ignoredWhitespaceOnly .content.remove .contentText {
-          background-color: var(--view-background-color);
-        }
-
-        .content .contentText gr-diff-text:empty:after,
-        .content .contentText gr-legacy-text:empty:after,
-        .content .contentText:empty:after {
-          /* Newline, to ensure empty lines are one line-height tall. */
-          content: '\\A';
-        }
-
-        /* Context controls */
-        .contextControl {
-          display: var(--context-control-display, table-row-group);
-          background-color: transparent;
-          border: none;
-          --divider-height: var(--spacing-s);
-          --divider-border: 1px;
-        }
-        /* TODO: Is this still used? */
-        .contextControl gr-button gr-icon {
-          /* should match line-height of gr-button */
-          font-size: var(--line-height-mono, 18px);
-        }
-        .contextControl td:not(.lineNumButton) {
-          text-align: center;
-        }
-
-        /**
-          Padding rows behind context controls. Styled as a continuation of the
-          line gutters and code area.
-        */
-        .contextBackground > .contextLineNum {
-          background-color: var(--diff-blank-background-color);
-        }
-        .contextBackground > td:not(.contextLineNum) {
-          background-color: var(--view-background-color);
-        }
-        .contextBackground {
-          /**
-            One line of background behind the context expanders which they can
-            render on top of, plus some padding.
-          */
-          height: calc(var(--line-height-normal) + var(--spacing-s));
-        }
-
-        .dividerCell {
-          vertical-align: top;
-        }
-        .dividerRow.show-both .dividerCell {
-          height: var(--divider-height);
-        }
-        .dividerRow.show-above .dividerCell,
-        .dividerRow.show-above .dividerCell {
-          height: 0;
-        }
-
-        .br:after {
-          /* Line feed */
-          content: '\\A';
-        }
-        .tab {
-          display: inline-block;
-        }
-        .tab-indicator:before {
-          color: var(--diff-tab-indicator-color);
-          /* >> character */
-          content: '\\00BB';
-          position: absolute;
-        }
-        .special-char-indicator {
-          /* spacing so elements don't collide */
-          padding-right: var(--spacing-m);
-        }
-        .special-char-indicator:before {
-          color: var(--diff-tab-indicator-color);
-          content: '•';
-          position: absolute;
-        }
-        .special-char-warning {
-          /* spacing so elements don't collide */
-          padding-right: var(--spacing-m);
-        }
-        .special-char-warning:before {
-          color: var(--warning-foreground);
-          content: '!';
-          position: absolute;
-        }
-        /**
-          Is defined after other background-colors, such that this
-          rule wins in case of same specificity.
-        */
-        .trailing-whitespace,
-        .content .contentText .trailing-whitespace,
-        .trailing-whitespace .intraline,
-        .content .contentText .trailing-whitespace .intraline {
-          border-radius: var(--border-radius, 4px);
-          background-color: var(--diff-trailing-whitespace-indicator);
-        }
-        #diffHeader {
-          background-color: var(--table-header-background-color);
-          border-bottom: 1px solid var(--border-color);
-          color: var(--link-color);
-          padding: var(--spacing-m) 0 var(--spacing-m) 48px;
-        }
-        #diffTable {
-          /* for gr-selection-action-box positioning */
-          position: relative;
-        }
-        #diffTable:focus {
-          outline: none;
-        }
-        #loadingError,
-        #sizeWarning {
-          display: block;
-          margin: var(--spacing-l) auto;
-          max-width: 60em;
-          text-align: center;
-        }
-        #loadingError {
-          color: var(--error-text-color);
-        }
-        #sizeWarning gr-button {
-          margin: var(--spacing-l);
-        }
-        .target-row td.blame {
-          background: var(--diff-selection-background-color);
-        }
-        td.lost div {
-          background-color: var(--info-background);
-        }
-        td.lost div.lost-message {
-          font-family: var(--font-family, 'Roboto');
-          font-size: var(--font-size-normal, 14px);
-          line-height: var(--line-height-normal);
-          padding: var(--spacing-s) 0;
-        }
-        td.lost div.lost-message gr-icon {
-          padding: 0 var(--spacing-s) 0 var(--spacing-m);
-          color: var(--blue-700);
-        }
-
-        col.sign,
-        td.sign {
-          display: none;
-        }
-
-        /* Sign column should only be shown in high-contrast mode. */
-        :host(.with-sign-col) col.sign {
-          display: table-column;
-        }
-        :host(.with-sign-col) td.sign {
-          display: table-cell;
-        }
-        col.blame {
-          display: none;
-        }
-        td.blame {
-          display: none;
-          padding: 0 var(--spacing-m);
-          white-space: pre;
-        }
-        :host(.showBlame) col.blame {
-          display: table-column;
-        }
-        :host(.showBlame) td.blame {
-          display: table-cell;
-        }
-        td.blame > span {
-          opacity: 0.6;
-        }
-        td.blame > span.startOfRange {
-          opacity: 1;
-        }
-        td.blame .blameDate {
-          font-family: var(--monospace-font-family);
-          color: var(--link-color);
-          text-decoration: none;
-        }
-        .responsive td.blame {
-          overflow: hidden;
-          width: 200px;
-        }
-        /** Support the line length indicator **/
-        .responsive td.content .contentText {
-          /**
-            Same strategy as in
-            https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
-          */
-          background-image: linear-gradient(
-            var(--line-length-indicator-color),
-            var(--line-length-indicator-color)
-          );
-          background-size: 1px 100%;
-          background-position: var(--line-limit-marker) 0;
-          background-repeat: no-repeat;
-        }
-        .newlineWarning {
-          color: var(--deemphasized-text-color);
-          text-align: center;
-        }
-        .newlineWarning.hidden {
-          display: none;
-        }
-        .lineNum.COVERED .lineNumButton {
-          color: var(
-            --coverage-covered-line-num-color,
-            var(--deemphasized-text-color)
-          );
-          background-color: var(--coverage-covered, #e0f2f1);
-        }
-        .lineNum.NOT_COVERED .lineNumButton {
-          color: var(
-            --coverage-covered-line-num-color,
-            var(--deemphasized-text-color)
-          );
-          background-color: var(--coverage-not-covered, #ffd1a4);
-        }
-        .lineNum.PARTIALLY_COVERED .lineNumButton {
-          color: var(
-            --coverage-covered-line-num-color,
-            var(--deemphasized-text-color)
-          );
-          background: linear-gradient(
-            to right bottom,
-            var(--coverage-not-covered, #ffd1a4) 0%,
-            var(--coverage-not-covered, #ffd1a4) 50%,
-            var(--coverage-covered, #e0f2f1) 50%,
-            var(--coverage-covered, #e0f2f1) 100%
-          );
-        }
-
-        // TODO: Investigate whether this CSS is still necessary.
-        /** BEGIN: Select and copy for Polymer 2 */
-        /**
-          Below was copied and modified from the original css in
-          gr-diff-selection.html
-        */
-        .content,
-        .contextControl,
-        .blame {
-          -webkit-user-select: none;
-          -moz-user-select: none;
-          -ms-user-select: none;
-          user-select: none;
-        }
-
-        .selected-left:not(.selected-comment)
-          .side-by-side
-          .left
-          + .content
-          .contentText,
-        .selected-right:not(.selected-comment)
-          .side-by-side
-          .right
-          + .content
-          .contentText,
-        .selected-left:not(.selected-comment)
-          .unified
-          .left.lineNum
-          ~ .content:not(.both)
-          .contentText,
-        .selected-right:not(.selected-comment)
-          .unified
-          .right.lineNum
-          ~ .content
-          .contentText,
-        .selected-left.selected-comment .side-by-side .left + .content .message,
-        .selected-right.selected-comment
-          .side-by-side
-          .right
-          + .content
-          .message
-          :not(.collapsedContent),
-        .selected-comment .unified .message :not(.collapsedContent),
-        .selected-blame .blame {
-          -webkit-user-select: text;
-          -moz-user-select: text;
-          -ms-user-select: text;
-          user-select: text;
-        }
-
-        /** Make comments and check results selectable when selected */
-        .selected-left.selected-comment
-          ::slotted(.comment-thread[diff-side='left']),
-        .selected-right.selected-comment
-          ::slotted(.comment-thread[diff-side='right']) {
-          -webkit-user-select: text;
-          -moz-user-select: text;
-          -ms-user-select: text;
-          user-select: text;
-        }
-        /** END: Select and copy for Polymer 2 */
-
-        .whitespace-change-only-message {
-          background-color: var(--diff-context-control-background-color);
-          border: 1px solid var(--diff-context-control-border-color);
-          text-align: center;
-        }
-
-        .token-highlight {
-          background-color: var(--token-highlighting-color, #fffd54);
-        }
-
-        gr-selection-action-box {
-          /**
-          * Needs z-index to appear above wrapped content, since it's inserted
-          * into DOM before it.
-          */
-          z-index: 10;
-        }
-
-        gr-diff-image-new,
-        gr-diff-image-old,
-        gr-diff-section,
-        gr-context-controls-section,
-        gr-diff-row {
-          display: contents;
-        }
-      `,
+      grDiffStyles,
     ];
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 227ac2d..f0826ad 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -3142,18 +3142,25 @@
           element,
           /* HTML */ `
             <div class="diffContainer sideBySide">
+              <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
+              <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
               <table class="selected-right" id="diffTable">
                 <colgroup>
                   <col class="blame gr-diff" />
-                  <col class="gr-diff" width="48" />
-                  <col class="gr-diff" width="48" />
-                  <col class="gr-diff" />
+                  <col class="gr-diff left" width="48" />
+                  <col class="gr-diff left sign" />
+                  <col class="gr-diff left" />
+                  <col class="gr-diff right" width="48" />
+                  <col class="gr-diff right sign" />
+                  <col class="gr-diff right" />
                 </colgroup>
                 <tbody class="binary-diff gr-diff"></tbody>
-                <tbody class="binary-diff gr-diff">
+                <tbody class="both gr-diff section">
                   <tr
-                    aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
-                    class="both diff-row gr-diff unified"
+                    aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+                    class="diff-row gr-diff side-by-side"
+                    left-type="both"
+                    right-type="both"
                     tabindex="-1"
                   >
                     <td class="blame gr-diff" data-line-number="FILE"></td>
@@ -3168,6 +3175,14 @@
                         File
                       </button>
                     </td>
+                    <td class="gr-diff left no-intraline-info sign"></td>
+                    <td
+                      class="both content file gr-diff left no-intraline-info"
+                    >
+                      <div class="thread-group" data-side="left">
+                        <slot name="left-FILE"> </slot>
+                      </div>
+                    </td>
                     <td class="gr-diff lineNum right" data-value="FILE">
                       <button
                         aria-label="Add file comment"
@@ -3179,19 +3194,23 @@
                         File
                       </button>
                     </td>
+                    <td class="gr-diff no-intraline-info right sign"></td>
                     <td
                       class="both content file gr-diff no-intraline-info right"
                     >
-                      <div>
-                        <span> Difference in binary files </span>
-                      </div>
                       <div class="thread-group" data-side="right">
                         <slot name="right-FILE"> </slot>
-                        <slot name="left-FILE"> </slot>
                       </div>
                     </td>
                   </tr>
                 </tbody>
+                <tbody class="binary-diff gr-diff">
+                  <tr class="gr-diff">
+                    <td class="gr-diff" colspan="5">
+                      <span> Difference in binary files </span>
+                    </td>
+                  </tr>
+                </tbody>
               </table>
             </div>
           `
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index b6b9de7..f5d7fef 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -5,6 +5,7 @@
  */
 import {
   BasePatchSetNum,
+  ChangeInfo,
   EditInfo,
   EDIT,
   PARENT,
@@ -13,25 +14,20 @@
   PreferencesInfo,
   RevisionPatchSetNum,
   PatchSetNumber,
+  CommitId,
 } from '../../types/common';
-import {DefaultBase} from '../../constants/constants';
-import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
-import {
-  map,
-  filter,
-  withLatestFrom,
-  startWith,
-  switchMap,
-} from 'rxjs/operators';
+import {ChangeStatus, DefaultBase} from '../../constants/constants';
+import {combineLatest, from, Observable, forkJoin, of} from 'rxjs';
+import {map, filter, withLatestFrom, switchMap} from 'rxjs/operators';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
   computeLatestPatchNumWithEdit,
+  findEdit,
+  sortRevisions,
 } from '../../utils/patch-set-util';
-import {ParsedChangeInfo} from '../../types/types';
-import {fireAlert} from '../../utils/event-util';
-
-import {ChangeInfo} from '../../types/common';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
+import {fireAlert, fireTitleChange} from '../../utils/event-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {select} from '../../utils/observable-util';
 import {assertIsDefined} from '../../utils/common-util';
@@ -40,12 +36,18 @@
 import {define} from '../dependency';
 import {isOwner} from '../../utils/change-util';
 import {
+  ChangeChildView,
   ChangeViewModel,
   createChangeUrl,
   createDiffUrl,
   createEditUrl,
 } from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+import {getRevertCreatedChangeIds} from '../../utils/message-util';
+import {computeTruncatedPath} from '../../utils/path-list-util';
+import {PluginLoader} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+import {Timing} from '../../constants/reporting';
 
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
@@ -69,6 +71,39 @@
    * Undefined means it's still loading and empty set means no files reviewed.
    */
   reviewedFiles?: string[];
+  /**
+   * Either filled from `change.mergeable`, or from a dedicated REST API call.
+   * Is initially `undefined`, such that you can identify whether this
+   * information has already been loaded once for this change or not. Will never
+   * go back to `undefined` after being set for a change.
+   */
+  mergeable?: boolean;
+}
+
+/**
+ * `change.revisions` is a dictionary mapping the revision sha to RevisionInfo,
+ * but the info object itself does not contain the sha, which is a problem when
+ * working with just the info objects.
+ *
+ * So we are iterating over the map here and are assigning the sha map key to
+ * the property `revision.commit.commit`.
+ *
+ * As usual we are treating data objects as immutable, so we are doind a lot of
+ * cloning here.
+ */
+export function updateRevisionsWithCommitShas(changeInput?: ParsedChangeInfo) {
+  if (!changeInput?.revisions) return changeInput;
+  const changeOutput = {...changeInput, revisions: {...changeInput.revisions}};
+  for (const sha of Object.keys(changeOutput.revisions)) {
+    const revision = changeOutput.revisions[sha];
+    if (revision?.commit && !revision.commit.commit) {
+      changeOutput.revisions[sha] = {
+        ...revision,
+        commit: {...revision.commit, commit: sha as CommitId},
+      };
+    }
+  }
+  return changeOutput;
 }
 
 /**
@@ -168,20 +203,36 @@
     changeState => changeState.loadingStatus
   );
 
+  public readonly loading$ = select(
+    this.changeLoadingStatus$,
+    status =>
+      status === LoadingStatus.LOADING || status === LoadingStatus.RELOADING
+  );
+
   public readonly reviewedFiles$ = select(
     this.state$,
     changeState => changeState?.reviewedFiles
   );
 
+  public readonly mergeable$ = select(
+    this.state$,
+    changeState => changeState.mergeable
+  );
+
   public readonly changeNum$ = select(this.change$, change => change?._number);
 
+  public readonly changeId$ = select(this.change$, change => change?.change_id);
+
   public readonly repo$ = select(this.change$, change => change?.project);
 
+  public readonly topic$ = select(this.change$, change => change?.topic);
+
+  public readonly status$ = select(this.change$, change => change?.status);
+
   public readonly labels$ = select(this.change$, change => change?.labels);
 
-  public readonly revisions$ = select(
-    this.change$,
-    change => change?.revisions
+  public readonly revisions$ = select(this.change$, change =>
+    sortRevisions(Object.values(change?.revisions || {}))
   );
 
   public readonly patchsets$ = select(this.change$, change =>
@@ -230,6 +281,12 @@
         viewModelState?.patchNum || latestPatchN
     );
 
+  /** The user can enter edit mode without an `EDIT` patchset existing yet. */
+  public readonly editMode$ = select(
+    combineLatest([this.viewModel.edit$, this.patchNum$]),
+    ([edit, patchNum]) => !!edit || patchNum === EDIT
+  );
+
   /**
    * Emits the base patchset number. This is identical to the
    * `viewModel.basePatchNum$`, but has some special logic for merges.
@@ -264,49 +321,56 @@
         computeBase(viewModelBasePatchNum, patchNum, change, preferences)
     );
 
+  private selectRevision(
+    revisionNum$: Observable<RevisionPatchSetNum | undefined>
+  ) {
+    return select(
+      combineLatest([this.revisions$, revisionNum$]),
+      ([revisions, patchNum]) => {
+        if (!revisions || !patchNum) return undefined;
+        return Object.values(revisions).find(
+          revision => revision._number === patchNum
+        );
+      }
+    );
+  }
+
+  public readonly revision$ = this.selectRevision(this.patchNum$);
+
+  public readonly latestRevision$ = this.selectRevision(this.latestPatchNum$);
+
   public readonly isOwner$: Observable<boolean> = select(
     combineLatest([this.change$, this.userModel.account$]),
     ([change, account]) => isOwner(change, account)
   );
 
-  // For usage in `combineLatest` we need `startWith` such that reload$ has an
-  // initial value.
-  readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
-    startWith(undefined)
+  public readonly messages$ = select(this.change$, change => change?.messages);
+
+  public readonly revertingChangeIds$ = select(this.messages$, messages =>
+    getRevertCreatedChangeIds(messages ?? [])
   );
 
   constructor(
     private readonly navigation: NavigationService,
     private readonly viewModel: ChangeViewModel,
     private readonly restApiService: RestApiService,
-    private readonly userModel: UserModel
+    private readonly userModel: UserModel,
+    private readonly pluginLoader: PluginLoader,
+    private readonly reporting: ReportingService
   ) {
     super(initialState);
     this.subscriptions = [
-      combineLatest([this.viewModel.changeNum$, this.reload$])
-        .pipe(
-          map(([changeNum, _]) => changeNum),
-          switchMap(changeNum => {
-            if (changeNum !== undefined) this.updateStateLoading(changeNum);
-            const change = from(this.restApiService.getChangeDetail(changeNum));
-            const edit = from(this.restApiService.getChangeEdit(changeNum));
-            return forkJoin([change, edit]);
-          }),
-          withLatestFrom(this.viewModel.patchNum$),
-          map(([[change, edit], patchNum]) =>
-            updateChangeWithEdit(change, edit, patchNum)
-          )
-        )
-        .subscribe(change => {
-          // The change service is currently a singleton, so we have to be
-          // careful to avoid situations where the application state is
-          // partially set for the old change where the user is coming from,
-          // and partially for the new change where the user is navigating to.
-          // So setting the change explicitly to undefined when the user
-          // moves away from diff and change pages (changeNum === undefined)
-          // helps with that.
-          this.updateStateChange(change ?? undefined);
-        }),
+      this.loadChange(),
+      this.loadMergeable(),
+      this.loadReviewedFiles(),
+      this.setOverviewTitle(),
+      this.setDiffTitle(),
+      this.setEditTitle(),
+      this.reportChangeReload(),
+      this.reportSendReply(),
+      this.fireShowChange(),
+      this.refuseEditForOpenChange(),
+      this.refuseEditForClosedChange(),
       this.change$.subscribe(change => (this.change = change)),
       this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
       this.basePatchNum$.subscribe(
@@ -315,20 +379,215 @@
       this.latestPatchNum$.subscribe(
         latestPatchNum => (this.latestPatchNum = latestPatchNum)
       ),
-      combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$])
-        .pipe(
-          switchMap(([patchNum, changeNum, loggedIn]) => {
-            if (!changeNum || !patchNum || !loggedIn) {
-              this.updateStateReviewedFiles([]);
-              return of(undefined);
-            }
-            return from(this.fetchReviewedFiles(patchNum, changeNum));
-          })
-        )
-        .subscribe(),
     ];
   }
 
+  private reportSendReply() {
+    return this.changeLoadingStatus$.subscribe(loadingStatus => {
+      // We are ending the timer on each change load, because ending a timer
+      // that was not started is a no-op. :-)
+      if (loadingStatus === LoadingStatus.LOADED) {
+        this.reporting.timeEnd(Timing.SEND_REPLY);
+      }
+    });
+  }
+
+  private reportChangeReload() {
+    return this.changeLoadingStatus$.subscribe(loadingStatus => {
+      if (
+        loadingStatus === LoadingStatus.LOADING ||
+        loadingStatus === LoadingStatus.RELOADING
+      ) {
+        this.reporting.time(Timing.CHANGE_RELOAD);
+      }
+      if (
+        loadingStatus === LoadingStatus.LOADED ||
+        loadingStatus === LoadingStatus.NOT_LOADED
+      ) {
+        this.reporting.timeEnd(Timing.CHANGE_RELOAD);
+      }
+    });
+  }
+
+  private fireShowChange() {
+    return combineLatest([
+      this.viewModel.childView$,
+      this.change$,
+      this.patchNum$,
+      this.mergeable$,
+    ])
+      .pipe(
+        filter(
+          ([childView, change, patchNum, mergeable]) =>
+            childView === ChangeChildView.OVERVIEW &&
+            !!change &&
+            !!patchNum &&
+            mergeable !== undefined
+        )
+      )
+      .subscribe(([_, change, patchNum, mergeable]) => {
+        this.pluginLoader.jsApiService.handleShowChange({
+          change,
+          patchNum,
+          // `?? null` is for the TypeScript compiler only. We have a
+          // `mergeable !== undefined` filter above, so this cannot happen.
+          // It would be nice to change `ShowChangeDetail` to accept `undefined`
+          // instaed of `null`, but that would be a Plugin API change ...
+          info: {mergeable: mergeable ?? null},
+        });
+      });
+  }
+
+  private refuseEditForOpenChange() {
+    return combineLatest([this.revisions$, this.patchNum$, this.status$])
+      .pipe(
+        filter(
+          ([revisions, patchNum, status]) =>
+            status === ChangeStatus.NEW &&
+            revisions.length > 0 &&
+            patchNum === EDIT
+        )
+      )
+      .subscribe(([revisions]) => {
+        const editRev = findEdit(revisions);
+        if (!editRev) {
+          const msg = 'Change edit not found. Please create a change edit.';
+          fireAlert(document, msg);
+          this.navigateToChangeResetReload();
+        }
+      });
+  }
+
+  private refuseEditForClosedChange() {
+    return combineLatest([
+      this.revisions$,
+      this.viewModel.edit$,
+      this.patchNum$,
+      this.status$,
+    ])
+      .pipe(
+        filter(
+          ([revisions, edit, patchNum, status]) =>
+            (status === ChangeStatus.ABANDONED ||
+              status === ChangeStatus.MERGED) &&
+            revisions.length > 0 &&
+            (patchNum === EDIT || edit)
+        )
+      )
+      .subscribe(([revisions]) => {
+        const editRev = findEdit(revisions);
+        if (!editRev) {
+          const msg =
+            'Change edits cannot be created if change is merged ' +
+            'or abandoned. Redirecting to non edit mode.';
+          fireAlert(document, msg);
+          this.navigateToChangeResetReload();
+        }
+      });
+  }
+
+  private setOverviewTitle() {
+    return combineLatest([this.viewModel.childView$, this.change$])
+      .pipe(
+        filter(([childView, _]) => childView === ChangeChildView.OVERVIEW),
+        map(([_, change]) => change),
+        filter(isDefined)
+      )
+      .subscribe(change => {
+        const title = `${change.subject} (${change._number})`;
+        fireTitleChange(title);
+      });
+  }
+
+  private setDiffTitle() {
+    return combineLatest([this.viewModel.childView$, this.viewModel.diffPath$])
+      .pipe(
+        filter(([childView, _]) => childView === ChangeChildView.DIFF),
+        map(([_, diffPath]) => diffPath),
+        filter(isDefined)
+      )
+      .subscribe(diffPath => {
+        const title = computeTruncatedPath(diffPath);
+        fireTitleChange(title);
+      });
+  }
+
+  private setEditTitle() {
+    return combineLatest([this.viewModel.childView$, this.viewModel.editPath$])
+      .pipe(
+        filter(([childView, _]) => childView === ChangeChildView.EDIT),
+        map(([_, editPath]) => editPath),
+        filter(isDefined)
+      )
+      .subscribe(editPath => {
+        const title = `Editing ${computeTruncatedPath(editPath)}`;
+        fireTitleChange(title);
+      });
+  }
+
+  private loadReviewedFiles() {
+    return combineLatest([
+      this.patchNum$,
+      this.changeNum$,
+      this.userModel.loggedIn$,
+    ])
+      .pipe(
+        switchMap(([patchNum, changeNum, loggedIn]) => {
+          if (!changeNum || !patchNum || !loggedIn) {
+            this.updateStateReviewedFiles([]);
+            return of(undefined);
+          }
+          return from(this.fetchReviewedFiles(patchNum, changeNum));
+        })
+      )
+      .subscribe();
+  }
+
+  private loadMergeable() {
+    return this.change$
+      .pipe(
+        switchMap(change => {
+          if (change?._number === undefined) return of(undefined);
+          if (change.mergeable !== undefined) return of(change.mergeable);
+          if (change.status === ChangeStatus.MERGED) return of(false);
+          if (change.status === ChangeStatus.ABANDONED) return of(false);
+          return from(
+            this.restApiService
+              .getMergeable(change._number)
+              .then(mergableInfo => mergableInfo?.mergeable ?? false)
+          );
+        })
+      )
+      .subscribe(mergeable => this.updateState({mergeable}));
+  }
+
+  private loadChange() {
+    return this.viewModel.changeNum$
+      .pipe(
+        switchMap(changeNum => {
+          if (changeNum !== undefined) this.updateStateLoading(changeNum);
+          const change = from(this.restApiService.getChangeDetail(changeNum));
+          const edit = from(this.restApiService.getChangeEdit(changeNum));
+          return forkJoin([change, edit]);
+        }),
+        withLatestFrom(this.viewModel.patchNum$),
+        map(([[change, edit], patchNum]) =>
+          updateChangeWithEdit(change, edit, patchNum)
+        ),
+        map(updateRevisionsWithCommitShas)
+      )
+      .subscribe(change => {
+        // The change service is currently a singleton, so we have to be
+        // careful to avoid situations where the application state is
+        // partially set for the old change where the user is coming from,
+        // and partially for the new change where the user is navigating to.
+        // So setting the change explicitly to undefined when the user
+        // moves away from diff and change pages (changeNum === undefined)
+        // helps with that.
+        this.updateStateChange(change ?? undefined);
+      });
+  }
+
   updateStateReviewedFiles(reviewedFiles: string[]) {
     this.updateState({reviewedFiles});
   }
@@ -430,12 +689,27 @@
     });
   }
 
+  // Mainly used for navigating from DIFF to OVERVIEW.
   navigateToChange(openReplyDialog = false) {
     const url = this.changeUrl(openReplyDialog);
     if (!url) return;
     this.navigation.setUrl(url);
   }
 
+  /**
+   * Wipes all URL parameters and other view state and goes to the change
+   * overview page, forcing a reload.
+   *
+   * This will also wipe the `patchNum`, so will always go to the latest
+   * patchset.
+   */
+  navigateToChangeResetReload() {
+    if (!this.change) return;
+    const url = createChangeUrl({change: this.change, forceReload: true});
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
   editUrl(editView: {path: string; lineNum?: number}) {
     if (!this.change) return;
     return createEditUrl({
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index c11c15b..e5d2e36 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -7,10 +7,12 @@
 import {ChangeStatus} from '../../constants/constants';
 import '../../test/common-test-setup';
 import {
+  TEST_NUMERIC_CHANGE_ID,
   createChange,
   createChangeMessageInfo,
   createChangeViewState,
   createEditInfo,
+  createMergeable,
   createParsedChange,
   createRevision,
 } from '../../test/test-data-generators';
@@ -29,13 +31,34 @@
 } from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
 import {getAppContext} from '../../services/app-context';
-import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
+import {
+  ChangeState,
+  LoadingStatus,
+  updateChangeWithEdit,
+  updateRevisionsWithCommitShas,
+} from './change-model';
 import {ChangeModel} from './change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {userModelToken} from '../user/user-model';
-import {changeViewModelToken} from '../views/change';
+import {
+  ChangeChildView,
+  ChangeViewModel,
+  changeViewModelToken,
+} from '../views/change';
 import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {SinonStub} from 'sinon';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {ShowChangeDetail} from '../../elements/shared/gr-js-api-interface/gr-js-api-types';
+
+suite('updateRevisionsWithCommitShas() tests', () => {
+  test('undefined edit', async () => {
+    const change = createParsedChange();
+    const updated = updateRevisionsWithCommitShas(change);
+    assert.equal(change?.revisions?.['abc'].commit?.commit, undefined);
+    assert.equal(updated?.revisions?.['abc'].commit?.commit, 'abc' as CommitId);
+  });
+});
 
 suite('updateChangeWithEdit() tests', () => {
   test('undefined change', async () => {
@@ -69,6 +92,7 @@
 });
 
 suite('change model tests', () => {
+  let changeViewModel: ChangeViewModel;
   let changeModel: ChangeModel;
   let knownChange: ParsedChangeInfo;
   const testCompleted = new Subject<void>();
@@ -84,25 +108,20 @@
   }
 
   setup(() => {
+    changeViewModel = testResolver(changeViewModelToken);
     changeModel = new ChangeModel(
       testResolver(navigationToken),
-      testResolver(changeViewModelToken),
+      changeViewModel,
       getAppContext().restApiService,
-      testResolver(userModelToken)
+      testResolver(userModelToken),
+      testResolver(pluginLoaderToken),
+      getAppContext().reportingService
     );
     knownChange = {
       ...createChange(),
       revisions: {
-        sha1: {
-          ...createRevision(1),
-          description: 'patch 1',
-          _number: 1 as PatchSetNumber,
-        },
-        sha2: {
-          ...createRevision(2),
-          description: 'patch 2',
-          _number: 2 as PatchSetNumber,
-        },
+        sha1: {...createRevision(1), description: 'patch 1'},
+        sha2: {...createRevision(2), description: 'patch 2'},
       },
       status: ChangeStatus.NEW,
       current_revision: 'abc' as CommitId,
@@ -115,6 +134,114 @@
     changeModel.finalize();
   });
 
+  suite('mergeability', async () => {
+    let getMergeableStub: SinonStub;
+    let mergeableApiResponse = false;
+
+    setup(() => {
+      getMergeableStub = stubRestApi('getMergeable').callsFake(() =>
+        Promise.resolve(createMergeable(mergeableApiResponse))
+      );
+    });
+
+    test('mergeability initially undefined', async () => {
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === undefined
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability true from change', async () => {
+      changeModel.updateStateChange({...knownChange, mergeable: true});
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === true
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability false from change', async () => {
+      changeModel.updateStateChange({...knownChange, mergeable: false});
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === true
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability false for MERGED change', async () => {
+      changeModel.updateStateChange({
+        ...knownChange,
+        status: ChangeStatus.MERGED,
+      });
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === false
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability false for ABANDONED change', async () => {
+      changeModel.updateStateChange({
+        ...knownChange,
+        status: ChangeStatus.ABANDONED,
+      });
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === false
+      );
+      assert.isFalse(getMergeableStub.called);
+    });
+
+    test('mergeability true from API', async () => {
+      mergeableApiResponse = true;
+      changeModel.updateStateChange(knownChange);
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === true
+      );
+      assert.isTrue(getMergeableStub.calledOnce);
+    });
+
+    test('mergeability false from API', async () => {
+      mergeableApiResponse = false;
+      changeModel.updateStateChange(knownChange);
+
+      waitUntilObserved(
+        changeModel.mergeable$,
+        mergeable => mergeable === false
+      );
+      assert.isTrue(getMergeableStub.calledOnce);
+    });
+  });
+
+  test('fireShowChange', async () => {
+    const pluginLoader = testResolver(pluginLoaderToken);
+    const jsApiService = pluginLoader.jsApiService;
+    const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
+
+    changeViewModel.updateState({
+      childView: ChangeChildView.OVERVIEW,
+      patchNum: 1 as PatchSetNumber,
+    });
+    changeModel.updateState({
+      change: createParsedChange(),
+      mergeable: true,
+    });
+
+    assert.isTrue(showChangeStub.calledOnce);
+    const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
+    assert.equal(detail.change?._number, createParsedChange()._number);
+    assert.equal(detail.patchNum, 1 as PatchSetNumber);
+    assert.equal(detail.info.mergeable, true);
+  });
+
   test('load a change', async () => {
     const promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
@@ -132,7 +259,7 @@
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 1);
-    assert.equal(state?.change, knownChange);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
   test('reload a change', async () => {
@@ -143,17 +270,20 @@
     testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 1);
 
     // Reloading same change
     document.dispatchEvent(new CustomEvent('reload'));
     state = await waitForLoadingStatus(LoadingStatus.RELOADING);
-    assert.equal(stub.callCount, 2);
-    assert.equal(state?.change, knownChange);
+    assert.equal(stub.callCount, 3);
+    assert.equal(stub.getCall(1).firstArg, undefined);
+    assert.equal(stub.getCall(2).firstArg, TEST_NUMERIC_CHANGE_ID);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
 
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
-    assert.equal(stub.callCount, 2);
-    assert.equal(state?.change, knownChange);
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
   test('navigating to another change', async () => {
@@ -183,7 +313,7 @@
     promise.resolve(otherChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 2);
-    assert.equal(state?.change, otherChange);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(otherChange));
   });
 
   test('navigating to dashboard', async () => {
@@ -211,7 +341,7 @@
     testResolver(changeViewModelToken).setState(createChangeViewState());
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 3);
-    assert.equal(state?.change, knownChange);
+    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
   });
 
   test('changeModel.fetchChangeUpdates on latest', async () => {
@@ -294,4 +424,28 @@
     changeModel.updateStateChange(createParsedChange());
     assert.equal(spy.callCount, 2);
   });
+
+  test('revision$ selector latest', async () => {
+    changeViewModel.updateState({patchNum: undefined});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.revision$, x => x?._number === 2);
+  });
+
+  test('revision$ selector 1', async () => {
+    changeViewModel.updateState({patchNum: 1 as PatchSetNumber});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.revision$, x => x?._number === 1);
+  });
+
+  test('latestRevision$ selector latest', async () => {
+    changeViewModel.updateState({patchNum: undefined});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.latestRevision$, x => x?._number === 2);
+  });
+
+  test('latestRevision$ selector 1', async () => {
+    changeViewModel.updateState({patchNum: 1 as PatchSetNumber});
+    changeModel.updateState({change: knownChange});
+    await waitUntilObserved(changeModel.latestRevision$, x => x?._number === 2);
+  });
 });
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index b7ff16f..c01b718 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -22,6 +22,8 @@
 import {define} from '../dependency';
 import {ChangeModel} from './change-model';
 import {CommentsModel} from '../comments/comments-model';
+import {Timing} from '../../constants/reporting';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 
 export type FileNameToNormalizedFileInfoMap = {
   [name: string]: NormalizedFileInfo;
@@ -141,10 +143,13 @@
   constructor(
     readonly changeModel: ChangeModel,
     readonly commentsModel: CommentsModel,
-    readonly restApiService: RestApiService
+    readonly restApiService: RestApiService,
+    private readonly reporting: ReportingService
   ) {
     super(initialState);
     this.subscriptions = [
+      this.reportChangeDataStart(),
+      this.reportChangeDataEnd(),
       this.subscribeToFiles(
         (psLeft, psRight) => {
           return {basePatchNum: psLeft, patchNum: psRight};
@@ -176,6 +181,26 @@
     ];
   }
 
+  private reportChangeDataStart() {
+    return combineLatest([this.changeModel.loading$]).subscribe(
+      ([changeLoading]) => {
+        if (changeLoading) {
+          this.reporting.time(Timing.CHANGE_DATA);
+        }
+      }
+    );
+  }
+
+  private reportChangeDataEnd() {
+    return combineLatest([this.changeModel.loading$, this.files$]).subscribe(
+      ([changeLoading, files]) => {
+        if (!changeLoading && files.length > 0) {
+          this.reporting.timeEnd(Timing.CHANGE_DATA);
+        }
+      }
+    );
+  }
+
   private subscribeToFiles(
     rangeChooser: (
       basePatchNum: BasePatchSetNum,
@@ -184,13 +209,12 @@
     filesToState: (files: NormalizedFileInfo[]) => Partial<FilesState>
   ) {
     return combineLatest([
-      this.changeModel.reload$,
       this.changeModel.changeNum$,
       this.changeModel.basePatchNum$,
       this.changeModel.patchNum$,
     ])
       .pipe(
-        switchMap(([_, changeNum, basePatchNum, patchNum]) => {
+        switchMap(([changeNum, basePatchNum, patchNum]) => {
           if (!changeNum || !patchNum) return of({});
           const range = rangeChooser(basePatchNum, patchNum);
           if (!range) return of({});
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts
new file mode 100644
index 0000000..9ae4295
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -0,0 +1,242 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+  SubmittedTogetherInfo,
+} from '../../types/common';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {ChangeModel} from './change-model';
+import {combineLatest, forkJoin, from, of} from 'rxjs';
+import {map, switchMap} from 'rxjs/operators';
+import {ConfigModel} from '../config/config-model';
+import {ChangeStatus} from '../../api/rest-api';
+import {isDefined} from '../../types/types';
+
+export interface RelatedChangesState {
+  /** `undefined` means "not yet loaded". */
+  relatedChanges?: RelatedChangeAndCommitInfo[];
+  submittedTogether?: SubmittedTogetherInfo;
+  cherryPicks?: ChangeInfo[];
+  conflictingChanges?: ChangeInfo[];
+  sameTopicChanges?: ChangeInfo[];
+  revertingChanges: ChangeInfo[];
+}
+
+const initialState: RelatedChangesState = {
+  relatedChanges: undefined,
+  submittedTogether: undefined,
+  cherryPicks: undefined,
+  conflictingChanges: undefined,
+  sameTopicChanges: undefined,
+  revertingChanges: [],
+};
+
+export const relatedChangesModelToken = define<RelatedChangesModel>(
+  'related-changes-model'
+);
+
+export class RelatedChangesModel extends Model<RelatedChangesState> {
+  public readonly relatedChanges$ = select(
+    this.state$,
+    state => state.relatedChanges
+  );
+
+  public readonly submittedTogether$ = select(
+    this.state$,
+    state => state.submittedTogether
+  );
+
+  public readonly cherryPicks$ = select(
+    this.state$,
+    state => state.cherryPicks
+  );
+
+  public readonly conflictingChanges$ = select(
+    this.state$,
+    state => state.conflictingChanges
+  );
+
+  public readonly sameTopicChanges$ = select(
+    this.state$,
+    state => state.sameTopicChanges
+  );
+
+  /**
+   * Emits all changes that have reverted the current change, based on
+   * information from parsed change messages. Abandoned changes are not
+   * included.
+   */
+  public readonly revertingChanges$ = select(
+    this.state$,
+    state => state.revertingChanges
+  );
+
+  /**
+   * Emits one reverting change (if there is any) from revertingChanges$.
+   * It prefers MERGED changes. Otherwise the choice is random.
+   */
+  public readonly revertingChange$ = select(
+    this.revertingChanges$,
+    revertingChanges => {
+      if (revertingChanges.length === 0) return undefined;
+      const submittedRevert = revertingChanges.find(
+        c => c.status === ChangeStatus.MERGED
+      );
+      if (submittedRevert) return submittedRevert;
+      return revertingChanges[0];
+    }
+  );
+
+  /**
+   * Determines whether the change has a parent change. If there
+   * is a relation chain, and the change id is not the last item of the
+   * relation chain, then there is a parent.
+   */
+  public readonly hasParent$ = select(
+    combineLatest([this.changeModel.change$, this.relatedChanges$]),
+    ([change, relatedChanges]) => {
+      if (!change) return undefined;
+      if (relatedChanges === undefined) return undefined;
+      if (relatedChanges.length === 0) return false;
+      const lastChangeId = relatedChanges[relatedChanges.length - 1].change_id;
+      return lastChangeId !== change.change_id;
+    }
+  );
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly configModel: ConfigModel,
+    readonly restApiService: RestApiService
+  ) {
+    super(initialState);
+    this.subscriptions = [
+      this.loadRelatedChanges(),
+      this.loadSubmittedTogether(),
+      this.loadCherryPicks(),
+      this.loadConflictingChanges(),
+      this.loadSameTopicChanges(),
+      this.loadRevertingChanges(),
+    ];
+  }
+
+  private loadRelatedChanges() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.latestPatchNum$,
+    ])
+      .pipe(
+        switchMap(([changeNum, latestPatchNum]) => {
+          if (!changeNum || !latestPatchNum) return of(undefined);
+          return from(
+            this.restApiService
+              .getRelatedChanges(changeNum, latestPatchNum)
+              .then(info => info?.changes ?? [])
+          );
+        })
+      )
+      .subscribe(relatedChanges => {
+        this.updateState({relatedChanges});
+      });
+  }
+
+  private loadSubmittedTogether() {
+    return this.changeModel.changeNum$
+      .pipe(
+        switchMap(changeNum => {
+          if (!changeNum) return of(undefined);
+          return from(
+            this.restApiService.getChangesSubmittedTogether(changeNum)
+          );
+        })
+      )
+      .subscribe(submittedTogether => {
+        this.updateState({submittedTogether});
+      });
+  }
+
+  private loadCherryPicks() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.changeId$,
+      this.changeModel.repo$,
+    ])
+      .pipe(
+        switchMap(([changeNum, changeId, repo]) => {
+          if (!changeNum || !changeId || !repo) return of(undefined);
+          return from(
+            this.restApiService.getChangeCherryPicks(repo, changeId, changeNum)
+          );
+        })
+      )
+      .subscribe(cherryPicks => {
+        this.updateState({cherryPicks});
+      });
+  }
+
+  private loadConflictingChanges() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.status$,
+      this.changeModel.mergeable$,
+    ])
+      .pipe(
+        switchMap(([changeNum, status, mergeable]) => {
+          if (!changeNum || !status || !mergeable) return of(undefined);
+          if (status !== ChangeStatus.NEW) return of(undefined);
+          return from(this.restApiService.getChangeConflicts(changeNum));
+        })
+      )
+      .subscribe(conflictingChanges => {
+        this.updateState({conflictingChanges});
+      });
+  }
+
+  private loadSameTopicChanges() {
+    return combineLatest([
+      this.changeModel.changeNum$,
+      this.changeModel.topic$,
+      this.configModel.serverConfig$,
+    ])
+      .pipe(
+        switchMap(([changeNum, topic, config]) => {
+          if (!changeNum || !topic || !config) return of(undefined);
+          if (config.change.submit_whole_topic) return of(undefined);
+          return from(
+            this.restApiService.getChangesWithSameTopic(topic, {
+              openChangesOnly: true,
+              changeToExclude: changeNum,
+            })
+          );
+        })
+      )
+      .subscribe(sameTopicChanges => {
+        this.updateState({sameTopicChanges});
+      });
+  }
+
+  private loadRevertingChanges() {
+    return this.changeModel.revertingChangeIds$
+      .pipe(
+        switchMap(changeIds => {
+          if (!changeIds?.length) return of([]);
+          return forkJoin(
+            changeIds.map(changeId =>
+              from(this.restApiService.getChange(changeId))
+            )
+          );
+        }),
+        map(changes => changes.filter(isDefined)),
+        map(changes => changes.filter(c => c.status !== ChangeStatus.ABANDONED))
+      )
+      .subscribe(revertingChanges => {
+        this.updateState({revertingChanges});
+      });
+  }
+}
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
new file mode 100644
index 0000000..295f284
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -0,0 +1,286 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {getAppContext} from '../../services/app-context';
+import {ChangeModel, changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {RelatedChangesModel} from './related-changes-model';
+import {configModelToken} from '../config/config-model';
+import {SinonStub} from 'sinon';
+import {
+  ChangeInfo,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+} from '../../types/common';
+import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
+import {
+  createParsedChange,
+  createRelatedChangesInfo,
+  createRelatedChangeAndCommitInfo,
+  createChange,
+  createChangeMessage,
+} from '../../test/test-data-generators';
+import {ChangeStatus, ReviewInputTag, TopicName} from '../../api/rest-api';
+import {MessageTag} from '../../constants/constants';
+
+suite('related-changes-model tests', () => {
+  let model: RelatedChangesModel;
+  let changeModel: ChangeModel;
+
+  setup(async () => {
+    changeModel = testResolver(changeModelToken);
+    model = new RelatedChangesModel(
+      changeModel,
+      testResolver(configModelToken),
+      getAppContext().restApiService
+    );
+    await waitUntilObserved(changeModel.change$, c => c === undefined);
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('register and fetch', async () => {
+    assert.equal('', '');
+  });
+
+  suite('related changes and hasParent', async () => {
+    let getRelatedChangesStub: SinonStub;
+    let getRelatedChangesResponse: RelatedChangesInfo;
+    let hasParent: boolean | undefined;
+
+    setup(() => {
+      getRelatedChangesStub = stubRestApi('getRelatedChanges').callsFake(() =>
+        Promise.resolve(getRelatedChangesResponse)
+      );
+      model.hasParent$.subscribe(x => (hasParent = x));
+    });
+
+    test('relatedChanges initially undefined', async () => {
+      await waitUntilObserved(
+        model.relatedChanges$,
+        relatedChanges => relatedChanges === undefined
+      );
+      assert.isFalse(getRelatedChangesStub.called);
+      assert.isUndefined(hasParent);
+    });
+
+    test('relatedChanges loading empty', async () => {
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.relatedChanges$,
+        relatedChanges => relatedChanges?.length === 0
+      );
+      assert.isTrue(getRelatedChangesStub.calledOnce);
+      assert.isFalse(hasParent);
+    });
+
+    test('relatedChanges loading one change', async () => {
+      getRelatedChangesResponse = {
+        ...createRelatedChangesInfo(),
+        changes: [createRelatedChangeAndCommitInfo()],
+      };
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.relatedChanges$,
+        relatedChanges => relatedChanges?.length === 1
+      );
+      assert.isTrue(getRelatedChangesStub.calledOnce);
+      assert.isTrue(hasParent);
+    });
+  });
+
+  suite('loadSubmittedTogether', async () => {
+    let getChangesSubmittedTogetherStub: SinonStub;
+    let getChangesSubmittedTogetherResponse: SubmittedTogetherInfo;
+
+    setup(() => {
+      getChangesSubmittedTogetherStub = stubRestApi(
+        'getChangesSubmittedTogether'
+      ).callsFake(() => Promise.resolve(getChangesSubmittedTogetherResponse));
+    });
+
+    test('submittedTogether initially undefined', async () => {
+      await waitUntilObserved(
+        model.submittedTogether$,
+        submittedTogether => submittedTogether === undefined
+      );
+      assert.isFalse(getChangesSubmittedTogetherStub.called);
+    });
+
+    test('submittedTogether emits', async () => {
+      getChangesSubmittedTogetherResponse = {
+        changes: [createChange()],
+        non_visible_changes: 0,
+      };
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.submittedTogether$,
+        submittedTogether => submittedTogether?.changes?.length === 1
+      );
+      assert.isTrue(getChangesSubmittedTogetherStub.calledOnce);
+    });
+  });
+
+  suite('loadCherryPicks', async () => {
+    let getChangeCherryPicksStub: SinonStub;
+    let getChangeCherryPicksResponse: ChangeInfo[];
+
+    setup(() => {
+      getChangeCherryPicksStub = stubRestApi('getChangeCherryPicks').callsFake(
+        () => Promise.resolve(getChangeCherryPicksResponse)
+      );
+    });
+
+    test('cherryPicks initially undefined', async () => {
+      await waitUntilObserved(
+        model.cherryPicks$,
+        cherryPicks => cherryPicks === undefined
+      );
+      assert.isFalse(getChangeCherryPicksStub.called);
+    });
+
+    test('cherryPicks emits', async () => {
+      getChangeCherryPicksResponse = [createChange()];
+      changeModel.updateStateChange({...createParsedChange()});
+
+      await waitUntilObserved(
+        model.cherryPicks$,
+        cherryPicks => cherryPicks?.length === 1
+      );
+      assert.isTrue(getChangeCherryPicksStub.calledOnce);
+    });
+  });
+
+  suite('loadConflictingChanges', async () => {
+    let getChangeConflictsStub: SinonStub;
+    let getChangeConflictsResponse: ChangeInfo[];
+
+    setup(() => {
+      getChangeConflictsStub = stubRestApi('getChangeConflicts').callsFake(() =>
+        Promise.resolve(getChangeConflictsResponse)
+      );
+    });
+
+    test('conflictingChanges initially undefined', async () => {
+      await waitUntilObserved(
+        model.conflictingChanges$,
+        conflictingChanges => conflictingChanges === undefined
+      );
+      assert.isFalse(getChangeConflictsStub.called);
+    });
+
+    test('conflictingChanges not loaded for merged changes', async () => {
+      getChangeConflictsResponse = [createChange()];
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        mergeable: true,
+        status: ChangeStatus.MERGED,
+      });
+
+      await waitUntilObserved(
+        model.conflictingChanges$,
+        conflictingChanges => conflictingChanges === undefined
+      );
+      assert.isFalse(getChangeConflictsStub.called);
+    });
+
+    test('conflictingChanges emits', async () => {
+      getChangeConflictsResponse = [createChange()];
+      changeModel.updateStateChange({...createParsedChange(), mergeable: true});
+
+      await waitUntilObserved(
+        model.conflictingChanges$,
+        conflictingChanges => conflictingChanges?.length === 1
+      );
+      assert.isTrue(getChangeConflictsStub.calledOnce);
+    });
+  });
+
+  suite('loadSameTopicChanges', async () => {
+    let getChangesWithSameTopicStub: SinonStub;
+    let getChangesWithSameTopicResponse: ChangeInfo[];
+
+    setup(() => {
+      getChangesWithSameTopicStub = stubRestApi(
+        'getChangesWithSameTopic'
+      ).callsFake(() => Promise.resolve(getChangesWithSameTopicResponse));
+    });
+
+    test('sameTopicChanges initially undefined', async () => {
+      await waitUntilObserved(
+        model.sameTopicChanges$,
+        sameTopicChanges => sameTopicChanges === undefined
+      );
+      assert.isFalse(getChangesWithSameTopicStub.called);
+    });
+
+    test('sameTopicChanges emits', async () => {
+      getChangesWithSameTopicResponse = [createChange()];
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        topic: 'test-topic' as TopicName,
+      });
+
+      await waitUntilObserved(
+        model.sameTopicChanges$,
+        sameTopicChanges => sameTopicChanges?.length === 1
+      );
+      assert.isTrue(getChangesWithSameTopicStub.calledOnce);
+    });
+  });
+
+  suite('loadRevertingChanges', async () => {
+    let getChangeStub: SinonStub;
+
+    setup(() => {
+      getChangeStub = stubRestApi('getChange').callsFake(() =>
+        Promise.resolve(createChange())
+      );
+    });
+
+    test('revertingChanges initially empty', async () => {
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges.length === 0
+      );
+      assert.isFalse(getChangeStub.called);
+    });
+
+    test('revertingChanges empty when change does not contain a revert message', async () => {
+      changeModel.updateStateChange(createParsedChange());
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges.length === 0
+      );
+      assert.isFalse(getChangeStub.called);
+    });
+
+    test('revertingChanges emits', async () => {
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        messages: [
+          {
+            ...createChangeMessage(),
+            message: 'Created a revert of this change as 123',
+            tag: MessageTag.TAG_REVERT as ReviewInputTag,
+          },
+        ],
+      });
+
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges?.length === 1
+      );
+      assert.isTrue(getChangeStub.calledOnce);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 2aa4aa4..729d46f 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -195,8 +195,6 @@
 
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
-  private readonly reloadListener: () => void;
-
   private readonly visibilityChangeListener: () => void;
 
   public checksSelectedPatchsetNumber$ = select(
@@ -414,8 +412,6 @@
       'visibilitychange',
       this.visibilityChangeListener
     );
-    this.reloadListener = () => this.reloadAll();
-    document.addEventListener('reload', this.reloadListener);
   }
 
   private reportStats(state: {[name: string]: ChecksProviderState}) {
@@ -459,7 +455,6 @@
   }
 
   override finalize() {
-    document.removeEventListener('reload', this.reloadListener);
     document.removeEventListener(
       'visibilitychange',
       this.visibilityChangeListener
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index e8d715e..026e5e5 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -19,11 +19,11 @@
 import {
   FixSuggestionInfo,
   FixReplacementInfo,
-  UnsavedInfo,
+  DraftInfo,
 } from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
-import {PROVIDED_FIX_ID} 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';
@@ -97,18 +97,16 @@
 ${result.message}`;
 }
 
-export function createPleaseFixComment(result: RunResult): UnsavedInfo {
+export function createPleaseFixComment(result: RunResult): DraftInfo {
   const pointer = result.codePointers?.[0];
   assertIsDefined(pointer, 'codePointer');
   return {
-    __unsaved: true,
+    ...createNew(pleaseFixMessage(result), true),
     path: pointer.path,
     patch_set: result.patchset as RevisionPatchSetNum,
     side: CommentSide.REVISION,
     line: pointer.range.end_line ?? pointer.range.start_line,
     range: pointer.range,
-    message: pleaseFixMessage(result),
-    unresolved: true,
   };
 }
 
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index a98fd58..eca8b7c 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -5,26 +5,29 @@
  */
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {
-  CommentBasics,
   CommentInfo,
   NumericChangeId,
   PatchSetNum,
   RevisionId,
   UrlEncodedCommentId,
-  PathToCommentsInfoMap,
   RobotCommentInfo,
   PathToRobotCommentsInfoMap,
   AccountInfo,
   DraftInfo,
-  UnsavedInfo,
   Comment,
+  SavingState,
+  isSaving,
+  isError,
   isDraft,
-  isDraftOrUnsaved,
-  isUnsaved,
+  isNew,
 } from '../../types/common';
 import {
   addPath,
+  createNew,
+  createNewPatchsetLevel,
+  id,
   isDraftThread,
+  isNewThread,
   reportingDetails,
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
@@ -36,9 +39,8 @@
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {ChangeModel} from '../change/change-model';
 import {Interaction, Timing} from '../../constants/reporting';
-import {assertIsDefined} 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';
 import {Model} from '../model';
 import {Deduping} from '../../api/reporting';
@@ -53,23 +55,22 @@
 } from 'rxjs/operators';
 import {isDefined} from '../../types/types';
 import {ChangeViewModel} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 
 export interface CommentState {
   /** undefined means 'still loading' */
-  comments?: PathToCommentsInfoMap;
+  comments?: {[path: string]: CommentInfo[]};
   /** undefined means 'still loading' */
   robotComments?: {[path: string]: RobotCommentInfo[]};
-  // All drafts are DraftInfo objects and have __draft = true set.
-  // Drafts have an id and are known to the backend. Unsaved drafts
-  // (see UnsavedInfo) do NOT belong in the application model.
+  // All drafts are DraftInfo objects and have `state` state set.
   /** undefined means 'still loading' */
   drafts?: {[path: string]: DraftInfo[]};
   // Ported comments only affect `CommentThread` properties, not individual
   // comments.
   /** undefined means 'still loading' */
-  portedComments?: PathToCommentsInfoMap;
+  portedComments?: {[path: string]: CommentInfo[]};
   /** undefined means 'still loading' */
-  portedDrafts?: PathToCommentsInfoMap;
+  portedDrafts?: {[path: string]: DraftInfo[]};
   /**
    * If a draft is discarded by the user, then we temporarily keep it in this
    * array in case the user decides to Undo the discard operation and bring the
@@ -96,7 +97,7 @@
   if (numPending === 0) {
     return 'All changes saved';
   }
-  return `Saving ${pluralize(numPending, 'draft')}...`;
+  return undefined;
 }
 
 // Private but used in tests.
@@ -168,7 +169,7 @@
 // Private but used in tests.
 export function setPortedComments(
   state: CommentState,
-  portedComments?: PathToCommentsInfoMap
+  portedComments?: {[path: string]: CommentInfo[]}
 ): CommentState {
   if (deepEqual(portedComments, state.portedComments)) return state;
   const nextState = {...state};
@@ -179,7 +180,7 @@
 // Private but used in tests.
 export function setPortedDrafts(
   state: CommentState,
-  portedDrafts?: PathToCommentsInfoMap
+  portedDrafts?: {[path: string]: DraftInfo[]}
 ): CommentState {
   if (deepEqual(portedDrafts, state.portedDrafts)) return state;
   const nextState = {...state};
@@ -204,7 +205,7 @@
 ): CommentState {
   const nextState = {...state};
   const drafts = [...nextState.discardedDrafts];
-  const index = drafts.findIndex(d => d.id === draftID);
+  const index = drafts.findIndex(draft => id(draft) === draftID);
   if (index === -1) {
     throw new Error('discarded draft not found');
   }
@@ -216,15 +217,14 @@
 /** 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 (!isDraft(draft)) throw new Error('draft is not a draft');
-  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  assert(!!draft.path, 'draft without path');
+  assert(isDraft(draft), 'draft is not a draft');
 
   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.id && d.id === draft.id);
+  const index = drafts[draft.path].findIndex(d => id(d) === id(draft));
   if (index !== -1) {
     drafts[draft.path][index] = draft;
   } else {
@@ -238,14 +238,12 @@
   draft: DraftInfo
 ): CommentState {
   const nextState = {...state};
-  if (!draft.path) throw new Error('draft path undefined');
-  if (!isDraft(draft)) throw new Error('draft is not a draft');
-  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  assert(!!draft.path, 'draft without path');
+  assert(isDraft(draft), 'draft is not a draft');
+
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
-  const index = (drafts[draft.path] || []).findIndex(
-    d => d.id && d.id === draft.id
-  );
+  const index = (drafts[draft.path] || []).findIndex(d => id(d) === id(draft));
   if (index === -1) return state;
   const discardedDraft = drafts[draft.path][index];
   drafts[draft.path] = [...drafts[draft.path]];
@@ -283,9 +281,22 @@
     commentState => commentState.drafts
   );
 
-  public readonly draftsCount$ = select(
+  public readonly draftsLoading$ = select(
     this.drafts$,
-    drafts => Object.values(drafts ?? {}).flat().length
+    drafts => drafts === undefined
+  );
+
+  public readonly draftsArray$ = select(this.drafts$, drafts =>
+    Object.values(drafts ?? {}).flat()
+  );
+
+  public readonly draftsSaved$ = select(this.draftsArray$, drafts =>
+    drafts.filter(d => !isNew(d))
+  );
+
+  public readonly draftsCount$ = select(
+    this.draftsSaved$,
+    drafts => drafts.length
   );
 
   public readonly portedComments$ = select(
@@ -298,21 +309,26 @@
     commentState => commentState.discardedDrafts
   );
 
-  public readonly patchsetLevelDrafts$ = select(this.drafts$, drafts =>
-    Object.values(drafts ?? {})
-      .flat()
-      .filter(
-        draft =>
-          draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
-          !draft.in_reply_to
-      )
+  public readonly savingInProgress$ = select(this.draftsArray$, drafts =>
+    drafts.some(isSaving)
+  );
+
+  public readonly savingError$ = select(this.draftsArray$, drafts =>
+    drafts.some(isError)
+  );
+
+  public readonly patchsetLevelDrafts$ = select(this.draftsArray$, drafts =>
+    drafts.filter(
+      draft =>
+        draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
+        !draft.in_reply_to
+    )
   );
 
   public readonly mentionedUsersInDrafts$: Observable<AccountInfo[]> =
-    this.drafts$.pipe(
-      switchMap(drafts => {
+    this.draftsArray$.pipe(
+      switchMap(comments => {
         const users: AccountInfo[] = [];
-        const comments = Object.values(drafts ?? {}).flat();
         for (const comment of comments) {
           users.push(...extractMentionedUsers(comment.message));
         }
@@ -334,12 +350,10 @@
     );
 
   public readonly mentionedUsersInUnresolvedDrafts$: Observable<AccountInfo[]> =
-    this.drafts$.pipe(
+    this.draftsArray$.pipe(
       switchMap(drafts => {
         const users: AccountInfo[] = [];
-        const comments = Object.values(drafts ?? {})
-          .flat()
-          .filter(c => c.unresolved);
+        const comments = drafts.filter(c => c.unresolved);
         for (const comment of comments) {
           users.push(...extractMentionedUsers(comment.message));
         }
@@ -378,8 +392,12 @@
     changeComments.getAllThreadsForChange()
   );
 
-  public readonly draftThreads$ = select(this.threads$, threads =>
-    threads.filter(isDraftThread)
+  public readonly threadsSaved$ = select(this.threads$, threads =>
+    threads.filter(t => !isNewThread(t))
+  );
+
+  public readonly draftThreadsSaved$ = select(this.threads$, threads =>
+    threads.filter(t => !isNewThread(t) && isDraftThread(t))
   );
 
   public readonly commentedPaths$ = select(
@@ -405,8 +423,6 @@
 
   private patchNum?: PatchSetNum;
 
-  private readonly reloadListener: () => void;
-
   private drafts: {[path: string]: DraftInfo[]} = {};
 
   private draftToastTask?: DelayedTask;
@@ -418,10 +434,29 @@
     private readonly changeModel: ChangeModel,
     private readonly accountsModel: AccountsModel,
     private readonly restApiService: RestApiService,
-    private readonly reporting: ReportingService
+    private readonly reporting: ReportingService,
+    private readonly navigation: NavigationService
   ) {
     super(initialState);
     this.subscriptions.push(
+      this.savingInProgress$.subscribe(savingInProgress => {
+        if (savingInProgress) {
+          this.navigation.blockNavigation('draft comment still saving');
+        } else {
+          this.navigation.releaseNavigation('draft comment still saving');
+        }
+      })
+    );
+    this.subscriptions.push(
+      this.savingError$.subscribe(savingError => {
+        if (savingError) {
+          this.navigation.blockNavigation('draft comment failed to save');
+        } else {
+          this.navigation.releaseNavigation('draft comment failed to save');
+        }
+      })
+    );
+    this.subscriptions.push(
       this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
     );
     this.subscriptions.push(
@@ -431,6 +466,16 @@
       this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
+      combineLatest([
+        this.draftsLoading$,
+        this.patchsetLevelDrafts$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([loading, plDraft, latestPatchNum]) => {
+        if (loading || plDraft.length > 0 || !latestPatchNum) return;
+        this.addNewDraft(createNewPatchsetLevel(latestPatchNum, '', false));
+      })
+    );
+    this.subscriptions.push(
       this.changeViewModel.changeNum$.subscribe(changeNum => {
         this.changeNum = changeNum;
         this.setState({...initialState});
@@ -447,16 +492,6 @@
         this.reloadAllPortedComments();
       })
     );
-    this.reloadListener = () => {
-      this.reloadAllComments();
-      this.reloadAllPortedComments();
-    };
-    document.addEventListener('reload', this.reloadListener);
-  }
-
-  override finalize() {
-    document.removeEventListener('reload', this.reloadListener);
-    super.finalize();
   }
 
   // Note that this does *not* reload ported comments.
@@ -551,101 +586,127 @@
     this.modifyState(s => setPortedDrafts(s, portedDrafts));
   }
 
-  async restoreDraft(id: UrlEncodedCommentId) {
-    const found = this.discardedDrafts?.find(d => d.id === id);
+  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,
-      __draft: undefined,
-      __unsaved: true,
+      ...createNew(),
     };
     await this.saveDraft(newDraft);
-    this.modifyState(s => deleteDiscardedDraft(s, id));
+    this.modifyState(s => deleteDiscardedDraft(s, draftId));
+  }
+
+  /**
+   * Adds a new draft without saving it.
+   *
+   * There is no equivalent `removeNewDraft()` method, because
+   * `discardDraft()` can be used.
+   */
+  addNewDraft(draft: DraftInfo) {
+    assert(isNew(draft), 'draft must be new');
+    this.modifyState(s => setDraft(s, draft));
   }
 
   /**
    * Saves a new or updates an existing draft.
-   * The model will only be updated when a successful response comes back.
+   *
+   * `draft.message` must not be empty: Use `discardDraft()` instead.
+   *
+   * Draft must not be in `SAVING` state already.
    */
-  async saveDraft(
-    draft: DraftInfo | UnsavedInfo,
-    showToast = true
-  ): Promise<DraftInfo> {
+  async saveDraft(draft: DraftInfo, showToast = true): Promise<DraftInfo> {
     assertIsDefined(this.changeNum, 'change number');
     assertIsDefined(draft.patch_set, 'patchset number of comment draft');
-    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+    assert(!!draft.message?.trim(), 'cannot save empty draft');
+    assert(!isSaving(draft), 'saving already in progress');
+
+    // optimistic update
+    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
     // relevant when it comes back. The user maybe have navigated away.
     const changeNum = this.changeNum;
     this.report(Interaction.SAVE_COMMENT, draft);
     if (showToast) this.showStartRequest();
-    const 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);
-    const result = await this.restApiService.saveDiffDraft(
-      changeNum,
-      draft.patch_set,
-      draft
-    );
-    if (changeNum !== this.changeNum) throw new Error('change changed');
-    if (!result.ok) {
-      if (showToast) this.handleFailedDraftRequest();
-      throw new Error(
-        `Failed to save draft comment: ${JSON.stringify(result)}`
+
+    let savedComment;
+    try {
+      const result = await this.restApiService.saveDiffDraft(
+        changeNum,
+        draft.patch_set,
+        draft
       );
+      if (changeNum !== this.changeNum) return draft;
+      if (!result.ok) throw new Error('request failed');
+      savedComment = (await this.restApiService.getResponseObject(
+        result
+      )) as unknown as CommentInfo;
+    } catch (error) {
+      if (showToast) this.handleFailedDraftRequest();
+      const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
+      this.modifyState(s => setDraft(s, draftError));
+      return draftError;
     }
-    const obj = await this.restApiService.getResponseObject(result);
-    const savedComment = obj as unknown as CommentInfo;
-    const updatedDraft = {
+
+    const draftSaved: DraftInfo = {
       ...draft,
       id: savedComment.id,
       updated: savedComment.updated,
-      __draft: true,
-      __unsaved: undefined,
+      savingState: SavingState.OK,
     };
-    timer.end({id: updatedDraft.id});
+    timer.end({id: draftSaved.id});
     if (showToast) this.showEndRequest();
-    this.modifyState(s => setDraft(s, updatedDraft));
-    this.report(Interaction.COMMENT_SAVED, updatedDraft);
-    return updatedDraft;
+    this.modifyState(s => setDraft(s, draftSaved));
+    this.report(Interaction.COMMENT_SAVED, draftSaved);
+    return draftSaved;
   }
 
   async discardDraft(draftId: UrlEncodedCommentId) {
     const draft = this.lookupDraft(draftId);
-    assertIsDefined(this.changeNum, 'change number');
     assertIsDefined(draft, `draft not found by id ${draftId}`);
     assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    assert(!isSaving(draft), 'saving already in progress');
 
-    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
-    // Saving the change number as to make sure that the response is still
-    // relevant when it comes back. The user maybe have navigated away.
-    const changeNum = this.changeNum;
-    this.report(Interaction.DISCARD_COMMENT, draft);
-    this.showStartRequest();
-    const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
-    const result = await this.restApiService.deleteDiffDraft(
-      changeNum,
-      draft.patch_set,
-      {id: draft.id}
-    );
-    timer.end({id: draft.id});
-    if (changeNum !== this.changeNum) throw new Error('change changed');
-    if (!result.ok) {
-      this.handleFailedDraftRequest();
-      throw new Error(
-        `Failed to discard draft comment: ${JSON.stringify(result)}`
-      );
-    }
-    this.showEndRequest();
+    // optimistic update
     this.modifyState(s => deleteDraft(s, draft));
+
+    // For "unsaved" drafts there is nothing to discard on the server side.
+    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.
+      assertIsDefined(this.changeNum, 'change number');
+      const changeNum = this.changeNum;
+      this.report(Interaction.DISCARD_COMMENT, draft);
+      this.showStartRequest();
+      const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
+      const result = await this.restApiService.deleteDiffDraft(
+        changeNum,
+        draft.patch_set,
+        {id: draft.id}
+      );
+      timer.end({id: draft.id});
+      if (changeNum !== this.changeNum) throw new Error('change changed');
+      if (!result.ok) {
+        this.handleFailedDraftRequest();
+        await this.restoreDraft(draftId);
+        throw new Error(
+          `Failed to discard draft comment: ${JSON.stringify(result)}`
+        );
+      }
+      this.showEndRequest();
+    }
+
     // We don't store empty discarded drafts and don't need an UNDO then.
     if (draft.message?.trim()) {
       fire(document, 'show-alert', {
         message: 'Draft Discarded',
         action: 'Undo',
-        callback: () => this.restoreDraft(draft.id),
+        callback: () => this.restoreDraft(draftId),
       });
     }
     this.report(Interaction.COMMENT_DISCARDED, draft);
@@ -657,9 +718,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,
@@ -670,7 +729,7 @@
     this.modifyState(s => updateComment(s, newComment));
   }
 
-  private report(interaction: Interaction, comment: CommentBasics) {
+  private report(interaction: Interaction, comment: Comment) {
     const details = reportingDetails(comment);
     this.reporting.reportInteraction(interaction, details);
   }
@@ -699,21 +758,17 @@
       this.numPendingDraftRequests,
       requestFailed
     );
+    if (!message) return;
     this.draftToastTask = debounce(
       this.draftToastTask,
-      () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        fireAlert(document.body, message);
-      },
+      () => fireAlert(document.body, message),
       TOAST_DEBOUNCE_INTERVAL
     );
   }
 
-  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+  private lookupDraft(commentId: UrlEncodedCommentId): DraftInfo | undefined {
     return Object.values(this.drafts)
       .flat()
-      .find(d => d.id === id);
+      .find(draft => id(draft) === commentId);
   }
 }
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index a689e42..0d3df42 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -11,6 +11,7 @@
 } from '../../test/test-data-generators';
 import {
   AccountInfo,
+  CommentInfo,
   EmailAddress,
   NumericChangeId,
   Timestamp,
@@ -24,13 +25,13 @@
 } from '../../test/test-data-generators';
 import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {getAppContext} from '../../services/app-context';
-import {PathToCommentsInfoMap} from '../../types/common';
 import {changeModelToken} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {accountsModelToken} from '../accounts-model/accounts-model';
 import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
 import {changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
@@ -76,7 +77,8 @@
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
     const diffCommentsSpy = stubRestApi('getDiffComments').returns(
       Promise.resolve({'foo.c': [createComment()]})
@@ -93,9 +95,9 @@
     const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
       Promise.resolve({})
     );
-    let comments: PathToCommentsInfoMap = {};
+    let comments: {[path: string]: CommentInfo[]} = {};
     subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
-    let portedComments: PathToCommentsInfoMap = {};
+    let portedComments: {[path: string]: CommentInfo[]} = {};
     subscriptions.push(
       model.portedComments$.subscribe(c => (portedComments = c ?? {}))
     );
@@ -134,7 +136,8 @@
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
     let mentionedUsers: AccountInfo[] = [];
     const draft = {...createDraft(), message: 'hey @abc@def.com'};
@@ -162,7 +165,8 @@
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
     let mentionedUsers: AccountInfo[] = [];
     const draft = {...createDraft(), message: 'hey @abc@def.com'};
@@ -200,7 +204,8 @@
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
-      getAppContext().reportingService
+      getAppContext().reportingService,
+      testResolver(navigationToken)
     );
 
     let changeComments: ChangeComments | undefined = undefined;
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 168e0f4..4c0df60 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -34,6 +34,11 @@
     configState => configState.serverConfig
   );
 
+  public download$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.download
+  );
+
   public mergeabilityComputationBehavior$ = select(
     this.serverConfig$,
     serverConfig => serverConfig?.change?.mergeability_computation_behavior
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index d2a0dd8..713d401 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -69,9 +69,16 @@
 
   /** for scrolling a Change Log message into view in gr-change-view */
   messageHash?: string;
-  /** for logging where the user came from */
+  /**
+   * For logging where the user came from. This is handled by the router, so
+   * this is not inspected by the model.
+   */
   usp?: string;
-  /** triggers all change related data to be reloaded */
+  /**
+   * Triggers all change related data to be reloaded. This is implemented by
+   * intercepting change view state updates and `forceReload` causing the view
+   * state to be wiped clean as `undefined` in an intermediate update.
+   */
   forceReload?: boolean;
   /** triggers opening the reply dialog */
   openReplyDialog?: boolean;
@@ -261,6 +268,20 @@
     state => state?.basePatchNum
   );
 
+  public readonly openReplyDialog$ = select(
+    this.state$,
+    state => state?.openReplyDialog
+  );
+
+  public readonly commentId$ = select(this.state$, state => state?.commentId);
+
+  public readonly edit$ = select(this.state$, state => !!state?.edit);
+
+  public readonly editPath$ = select(
+    this.state$,
+    state => state?.editView?.path
+  );
+
   public readonly diffPath$ = select(
     this.state$,
     state => state?.diffView?.path
@@ -278,7 +299,11 @@
 
   public readonly childView$ = select(this.state$, state => state?.childView);
 
-  public readonly tab$ = select(this.state$, state => state?.tab);
+  public readonly tab$ = select(this.state$, state => {
+    if (state?.commentId) return Tab.COMMENT_THREADS;
+    if (state?.tab) return state.tab;
+    return Tab.FILES;
+  });
 
   public readonly checksPatchset$ = select(
     this.state$,
@@ -310,6 +335,39 @@
         });
       }
     });
+    document.addEventListener('reload', this.reload);
+  }
+
+  override finalize(): void {
+    document.removeEventListener('reload', this.reload);
+  }
+
+  /**
+   * Calling this is the same as firing the 'reload' event. This is also the
+   * same as adding `forceReload` parameter in the URL. See below.
+   */
+  reload = () => {
+    const state = this.getState();
+    if (state !== undefined) this.forceLoad(state);
+  };
+
+  /**
+   * This is the destination of where the `reload()` method, the `reload` event
+   * and the `forceReload` URL parameter all end up.
+   */
+  private forceLoad(state: ChangeViewState) {
+    this.setState(undefined);
+    // We have to do this in a timeout, because we need the `undefined` value to
+    // be processed by all observers first and thus have the "reset" completed.
+    setTimeout(() => this.setState({...state, forceReload: undefined}));
+  }
+
+  override setState(state: ChangeViewState | undefined): void {
+    if (state?.forceReload) {
+      this.forceLoad(state);
+    } else {
+      super.setState(state);
+    }
   }
 
   toggleSelectedCheckRun(checkName: string) {
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 14fb253..8589ae3 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -68,6 +68,10 @@
   ServiceWorkerInstaller,
   serviceWorkerInstallerToken,
 } from './service-worker-installer';
+import {
+  RelatedChangesModel,
+  relatedChangesModelToken,
+} from '../models/change/related-changes-model';
 
 /**
  * The AppContext lazy initializator for all services
@@ -151,7 +155,9 @@
           resolver(navigationToken),
           resolver(changeViewModelToken),
           appContext.restApiService,
-          resolver(userModelToken)
+          resolver(userModelToken),
+          resolver(pluginLoaderToken),
+          appContext.reportingService
         ),
     ],
     [
@@ -162,7 +168,8 @@
           resolver(changeModelToken),
           resolver(accountsModelToken),
           appContext.restApiService,
-          appContext.reportingService
+          appContext.reportingService,
+          resolver(navigationToken)
         ),
     ],
     [
@@ -171,7 +178,8 @@
         new FilesModel(
           resolver(changeModelToken),
           resolver(commentsModelToken),
-          appContext.restApiService
+          appContext.restApiService,
+          appContext.reportingService
         ),
     ],
     [
@@ -180,6 +188,15 @@
         new ConfigModel(resolver(changeModelToken), appContext.restApiService),
     ],
     [
+      relatedChangesModelToken,
+      () =>
+        new RelatedChangesModel(
+          resolver(changeModelToken),
+          resolver(configModelToken),
+          appContext.restApiService
+        ),
+    ],
+    [
       pluginLoaderToken,
       () =>
         new PluginLoader(
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 973960e..7488e79 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -19,5 +19,4 @@
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   SUGGEST_EDIT = 'UiFeature__suggest_edit',
-  COMMENTS_CHIPS_IN_FILE_LIST = 'UiFeature__comments_chips_in_file_list',
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 49259b4..9b08a6e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -34,6 +34,7 @@
 
   appStarted(): void;
   onVisibilityChange(): void;
+  onFocusChange(): void;
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 55bb64c..61534a4 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -88,6 +88,9 @@
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
+// List of timers that should NOT be reset before a location change.
+const LOCATION_CHANGE_OK_TIMERS: (string | Timing)[] = [Timing.SEND_REPLY];
+
 const SLOW_RPC_THRESHOLD = 500;
 
 export function initErrorReporter(reportingService: ReportingService) {
@@ -185,6 +188,12 @@
   document.addEventListener('visibilitychange', () => {
     reportingService.onVisibilityChange();
   });
+  window.addEventListener('blur', () => {
+    reportingService.onFocusChange();
+  });
+  window.addEventListener('focus', () => {
+    reportingService.onFocusChange();
+  });
 }
 
 export function initClickReporter(reportingService: ReportingService) {
@@ -203,6 +212,62 @@
   });
 }
 
+/**
+ * Reports generic user interaction every x seconds to detect, if the user is
+ * present and is using the application somehow. If you just look at
+ * `document.visibilityState`, then the user may have left the browser open
+ * without locking the screen. So it helps to know whether some interaction is
+ * actually happening.
+ */
+export class InteractionReporter implements Finalizable {
+  /** Accumulates event names until the next round of interaction reporting. */
+  private interactionEvents = new Set<string>();
+
+  /** Allows clearing the interval timer. Mostly useful for tests. */
+  private intervalId?: number;
+
+  constructor(
+    private readonly reportingService: ReportingService,
+    private readonly reportingIntervalMs = 10 * 1000
+  ) {
+    const events = ['mousemove', 'scroll', 'wheel', 'keydown', 'pointerdown'];
+    for (const eventName of events) {
+      document.addEventListener(eventName, () =>
+        this.interactionEvents.add(eventName)
+      );
+    }
+
+    this.intervalId = window.setInterval(
+      () => this.report(),
+      this.reportingIntervalMs
+    );
+  }
+
+  finalize() {
+    window.clearInterval(this.intervalId);
+  }
+
+  private report() {
+    const active = this.interactionEvents.size > 0;
+    if (active) {
+      this.reportingService.reportInteraction(Interaction.USER_ACTIVE, {
+        events: [...this.interactionEvents],
+      });
+    } else if (document.visibilityState === 'visible') {
+      this.reportingService.reportInteraction(Interaction.USER_PASSIVE, {});
+    }
+    this.interactionEvents.clear();
+  }
+}
+
+let interactionReporter: InteractionReporter;
+
+export function initInteractionReporter(reportingService: ReportingService) {
+  if (!interactionReporter) {
+    interactionReporter = new InteractionReporter(reportingService);
+  }
+}
+
 export function initWebVitals(reportingService: ReportingService) {
   function reportWebVitalMetric(name: Timing, metric: Metric) {
     let score = metric.value;
@@ -470,6 +535,20 @@
     this._reportNavResTimes();
   }
 
+  onFocusChange() {
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.VISIBILITY,
+      LifeCycle.FOCUS,
+      undefined,
+      {
+        isVisible: document.visibilityState === 'visible',
+        hasFocus: document.hasFocus(),
+      },
+      false
+    );
+  }
+
   onVisibilityChange() {
     this.hiddenDurationTimer.onVisibilityChange();
     let eventName;
@@ -486,6 +565,8 @@
         undefined,
         {
           hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+          isVisible: document.visibilityState === 'visible',
+          hasFocus: document.hasFocus(),
         },
         false
       );
@@ -522,6 +603,7 @@
 
   beforeLocationChanged() {
     for (const prop of Object.keys(this._baselines)) {
+      if (LOCATION_CHANGE_OK_TIMERS.includes(prop)) continue;
       delete this._baselines[prop];
     }
     this.time(Timing.CHANGE_DISPLAYED);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index d4efbcc..fd1b88c 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -44,6 +44,9 @@
   onVisibilityChange: () => {
     log('onVisibilityChange');
   },
+  onFocusChange: () => {
+    log('onFocusChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index 9c5e20d..2d3dfe2 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -8,11 +8,14 @@
   GrReporting,
   DEFAULT_STARTUP_TIMERS,
   initErrorReporter,
+  InteractionReporter,
 } from './gr-reporting_impl';
 import {getAppContext} from '../app-context';
 import {Deduping} from '../../api/reporting';
-import {SinonFakeTimers} from 'sinon';
+import {SinonFakeTimers, SinonStub} from 'sinon';
 import {assert} from '@open-wc/testing';
+import {grReportingMock} from './gr-reporting_mock';
+import {Interaction} from '../../constants/reporting';
 
 suite('gr-reporting tests', () => {
   // We have to type as any because we access
@@ -563,3 +566,62 @@
     });
   });
 });
+
+suite('InteractionReporter', () => {
+  let interactionReporter: InteractionReporter;
+  let clock: SinonFakeTimers;
+  let stub: SinonStub;
+  let activeCalls: number[] = [];
+  let passiveCalls: number[] = [];
+
+  setup(() => {
+    clock = sinon.useFakeTimers(0);
+    activeCalls = [];
+    passiveCalls = [];
+    const reporting = grReportingMock;
+    stub = sinon
+      .stub(reporting, 'reportInteraction')
+      .callsFake((interaction: string | Interaction) => {
+        if (interaction === Interaction.USER_ACTIVE) {
+          activeCalls.push(clock.now);
+        }
+        if (interaction === Interaction.USER_PASSIVE) {
+          passiveCalls.push(clock.now);
+        }
+      });
+    interactionReporter = new InteractionReporter(reporting, 1000);
+  });
+
+  teardown(() => {
+    clock.restore();
+    interactionReporter.finalize();
+  });
+
+  test('interaction example', () => {
+    clock.tick(500);
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    document.dispatchEvent(new MouseEvent('scroll'));
+    document.dispatchEvent(new MouseEvent('wheel'));
+    clock.tick(1000);
+    clock.tick(1000);
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+
+    assert.sameOrderedMembers(activeCalls, [2000, 3000, 6000, 7000]);
+    assert.sameOrderedMembers(passiveCalls, [1000, 4000, 5000]);
+
+    assert.isUndefined(stub.getCall(0).args[1].events);
+    assert.sameMembers(stub.getCall(1).args[1].events, ['mousemove']);
+    assert.sameMembers(stub.getCall(2).args[1].events, [
+      'mousemove',
+      'scroll',
+      'wheel',
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index eed7675..68ec76a 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -93,7 +93,6 @@
   Password,
   PatchRange,
   PatchSetNum,
-  PathToCommentsInfoMap,
   PathToRobotCommentsInfoMap,
   PluginInfo,
   PreferencesInfo,
@@ -1806,7 +1805,8 @@
     }
     const options = listChangesOptionsToHex(
       ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT
+      ListChangesOption.CURRENT_COMMIT,
+      ListChangesOption.SUBMITTABLE
     );
     const params = {
       O: options,
@@ -1856,7 +1856,8 @@
       ListChangesOption.LABELS,
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT,
-      ListChangesOption.DETAILED_LABELS
+      ListChangesOption.DETAILED_LABELS,
+      ListChangesOption.SUBMITTABLE
     );
     const queryTerms = [`topic:${escapeAndWrapSearchOperatorValue(topic)}`];
     if (options?.openChangesOnly) {
@@ -2338,7 +2339,7 @@
 
   getDiffComments(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   getDiffComments(
     changeNum: NumericChangeId,
@@ -2439,7 +2440,7 @@
     changeNum: NumericChangeId,
     endpoint: '/comments' | '/drafts',
     params?: FetchParams
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   _getDiffComments(
     changeNum: NumericChangeId,
@@ -2474,7 +2475,7 @@
   ): Promise<
     | GetDiffCommentsOutput
     | GetDiffRobotCommentsOutput
-    | PathToCommentsInfoMap
+    | {[path: string]: CommentInfo[]}
     | PathToRobotCommentsInfoMap
     | undefined
   > {
@@ -2496,7 +2497,7 @@
         },
         noAcceptHeader
       ) as Promise<
-        PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
+        {[path: string]: CommentInfo[]} | PathToRobotCommentsInfoMap | undefined
       >;
 
     if (!basePatchNum && !patchNum && !path) {
@@ -2564,7 +2565,7 @@
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined> {
+  ): Promise<{[path: string]: CommentInfo[]} | undefined> {
     // maintaining a custom error function so that errors do not surface in UI
     const errFn: ErrorCallback = (response?: Response | null) => {
       if (response)
@@ -2578,24 +2579,24 @@
     });
   }
 
-  getPortedDrafts(
+  async getPortedDrafts(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined> {
+  ): Promise<{[path: string]: DraftInfo[]} | undefined> {
     // maintaining a custom error function so that errors do not surface in UI
     const errFn: ErrorCallback = (response?: Response | null) => {
       if (response)
         console.info(`Fetching ported drafts failed, ${response.status}`);
     };
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) return {};
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/ported_drafts/',
-        revision,
-        errFn,
-      });
-    });
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) return {};
+    const comments = (await this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/ported_drafts/',
+      revision,
+      errFn,
+    })) as {[path: string]: CommentInfo[]} | undefined;
+    return addDraftProp(comments);
   }
 
   saveDiffDraft(
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index b1f1884..24ba0e5 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -64,7 +64,6 @@
   Password,
   PatchRange,
   PatchSetNum,
-  PathToCommentsInfoMap,
   PathToRobotCommentsInfoMap,
   PluginInfo,
   PreferencesInfo,
@@ -412,16 +411,16 @@
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   getPortedDrafts(
     changeNum: NumericChangeId,
     revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   getDiffComments(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: CommentInfo[]} | undefined>;
   getDiffComments(
     changeNum: NumericChangeId,
     basePatchNum: PatchSetNum,
@@ -434,7 +433,7 @@
     patchNum?: PatchSetNum,
     path?: string
   ):
-    | Promise<PathToCommentsInfoMap | undefined>
+    | Promise<{[path: string]: CommentInfo[]} | undefined>
     | Promise<GetDiffCommentsOutput>;
 
   getDiffRobotComments(
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 6e363d3..f8e4160 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -33,7 +33,6 @@
   RepoAccessInfoMap,
   IncludedInInfo,
   CommentInfo,
-  PathToCommentsInfoMap,
   PluginInfo,
   DocResult,
   ContributorAgreementInfo,
@@ -59,6 +58,7 @@
   UrlEncodedRepoName,
   NumericChangeId,
   PreferencesInput,
+  DraftInfo,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
 import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -67,6 +67,7 @@
   createChange,
   createCommit,
   createConfig,
+  createMergeable,
   createPreferences,
   createServerInfo,
   createSubmittedTogetherInfo,
@@ -368,15 +369,15 @@
     return Promise.resolve(true);
   },
   getMergeable(): Promise<MergeableInfo | undefined> {
-    throw new Error('getMergeable() not implemented by RestApiMock.');
+    return Promise.resolve(createMergeable());
   },
   getPlugins(): Promise<{[p: string]: PluginInfo} | undefined> {
     return Promise.resolve({});
   },
-  getPortedComments(): Promise<PathToCommentsInfoMap | undefined> {
+  getPortedComments(): Promise<{[path: string]: CommentInfo[]} | undefined> {
     return Promise.resolve({});
   },
-  getPortedDrafts(): Promise<PathToCommentsInfoMap | undefined> {
+  getPortedDrafts(): Promise<{[path: string]: DraftInfo[]} | undefined> {
     return Promise.resolve({});
   },
   getPreferences(): Promise<PreferencesInfo | undefined> {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 6bee4a6..026e3b5 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -41,6 +41,8 @@
       setUrl: () => {},
       replaceUrl: () => {},
       finalize: () => {},
+      blockNavigation: () => {},
+      releaseNavigation: () => {},
     };
   });
   dependencies.set(
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index a3f24d4..dce8e4b 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -66,13 +66,12 @@
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
-  TimezoneOffset,
   UrlEncodedCommentId,
   UserConfigInfo,
   CommentThread,
   DraftInfo,
   ChangeMessage,
-  UnsavedInfo,
+  SavingState,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -93,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';
@@ -244,7 +243,6 @@
     name,
     email: `${name}@` as EmailAddress,
     date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
-    tz: 0 as TimezoneOffset,
   };
 }
 
@@ -674,10 +672,10 @@
   };
 }
 
-export function createMergeable(): MergeableInfo {
+export function createMergeable(mergeable = false): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
-    mergeable: false,
+    mergeable,
   };
 }
 
@@ -844,18 +842,16 @@
 export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    __draft: true,
+    savingState: SavingState.OK,
     ...extra,
   };
 }
 
-export function createUnsaved(extra: Partial<CommentInfo> = {}): UnsavedInfo {
+export function createNewDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    __unsaved: true,
-    id: undefined,
-    updated: undefined,
     ...extra,
+    ...createNew(),
   };
 }
 
@@ -995,6 +991,9 @@
 export function createThread(
   ...comments: Partial<CommentInfo | DraftInfo>[]
 ): CommentThread {
+  if (comments.length === 0) {
+    comments = [createComment()];
+  }
   return {
     comments: comments.map(c => createComment(c)),
     rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 19c3a7b..6e20dd4 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -16,26 +16,10 @@
 import {assert} from '@open-wc/testing';
 import {Route, ViewState} from '../models/views/base';
 import {PageContext} from '../elements/core/gr-router/gr-page';
+import {waitUntil} from '../utils/async-util';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
-
-export interface MockPromise<T> extends Promise<T> {
-  resolve: (value?: T) => void;
-  reject: (reason?: any) => void;
-}
-
-export function mockPromise<T = unknown>(): MockPromise<T> {
-  let res: (value?: T) => void;
-  let rej: (reason?: any) => void;
-  const promise: MockPromise<T> = new Promise<T | undefined>(
-    (resolve, reject) => {
-      res = resolve;
-      rej = reject;
-    }
-  ) as MockPromise<T>;
-  promise.resolve = res!;
-  promise.reject = rej!;
-  return promise;
-}
+export {mockPromise, waitUntil} from '../utils/async-util';
+export type {MockPromise} from '../utils/async-util';
 
 export function isHidden(el: Element | undefined | null) {
   if (!el) return true;
@@ -166,32 +150,6 @@
   return queryAndAssert<E>(el, selector);
 }
 
-export async function waitUntil(
-  predicate: (() => boolean) | (() => Promise<boolean>),
-  message = 'The waitUntil() predicate is still false after 1000 ms.',
-  timeout_ms = 1000
-): Promise<void> {
-  const start = Date.now();
-  let sleep = 0;
-  if (await predicate()) return Promise.resolve();
-  const error = new Error(message);
-  return new Promise((resolve, reject) => {
-    const waiter = async () => {
-      if (await predicate()) {
-        resolve();
-        return;
-      }
-      if (Date.now() - start >= timeout_ms) {
-        reject(error);
-        return;
-      }
-      setTimeout(waiter, sleep);
-      sleep = sleep === 0 ? 1 : sleep * 4;
-    };
-    waiter();
-  });
-}
-
 export async function waitUntilVisible(element: Element): Promise<void> {
   return new Promise(resolve => {
     whenVisible(element, () => resolve());
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index c68e8c9..b148780 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -105,7 +105,6 @@
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
-  TimezoneOffset,
   TopicName,
   UrlEncodedRepoName,
   UserConfigInfo,
@@ -200,7 +199,6 @@
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
-  TimezoneOffset,
   TopicName,
   UrlEncodedRepoName,
   UserConfigInfo,
@@ -697,52 +695,93 @@
   WIP = 'WIP',
 }
 
-export interface DraftCommentProps {
-  // This must be true for all drafts. Drafts received from the backend will be
-  // modified immediately with __draft:true before allowing them to get into
-  // the application state.
-  __draft: boolean;
+export enum SavingState {
+  /**
+   * Currently not saving. Not yet saved or last saving attempt successful.
+   */
+  // Possible prior states: SAVING
+  // Possible subsequent states: SAVING
+  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.
+   */
+  // Possible prior states: SAVING
+  // Possible subsequent states: SAVING
+  ERROR = 'ERROR',
 }
 
-export interface UnsavedCommentProps {
-  // This must be true for all unsaved comment drafts. An unsaved draft is
-  // always just local to a comment component like <gr-comment> or
-  // <gr-comment-thread>. Unsaved drafts will never appear in the application
-  // state.
-  __unsaved: boolean;
+export type DraftInfo = Omit<CommentInfo, 'id' | 'updated'> & {
+  // Must be set for all drafts.
+  // Drafts received from the backend will be modified immediately with
+  // `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 drafts known to the backend.
+  // Use the id() utility function for uniquely identifying drafts.
+  id?: UrlEncodedCommentId;
+  // 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;
 }
 
-export type DraftInfo = CommentInfo & DraftCommentProps;
-
-export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
-
-export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
+/**
+ * This is what human, robot and draft comments can agree upon.
+ *
+ * Note that `id` and `updated` must be considered optional, because we might
+ * be dealing with unsaved draft comments.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
 
 // TODO: Replace the CommentMap type with just an array of paths.
 export type CommentMap = {[path: string]: boolean};
 
-export function isRobot<T extends CommentBasics>(
-  x: T | DraftInfo | RobotCommentInfo | undefined
+export function isRobot<T extends Comment>(
+  x: T | RobotCommentInfo | undefined
 ): x is RobotCommentInfo {
   return !!x && !!(x as RobotCommentInfo).robot_id;
 }
 
-export function isDraft<T extends CommentBasics>(
+export function isDraft<T extends Comment>(
   x: T | DraftInfo | undefined
 ): x is DraftInfo {
-  return !!x && !!(x as DraftInfo).__draft;
+  return !!x && (x as DraftInfo).savingState !== undefined;
 }
 
-export function isUnsaved<T extends CommentBasics>(
-  x: T | UnsavedInfo | undefined
-): x is UnsavedInfo {
-  return !!x && !!(x as UnsavedInfo).__unsaved;
+export function isSaving<T extends Comment>(
+  x: T | DraftInfo | undefined
+): boolean {
+  return !!x && (x as DraftInfo).savingState === SavingState.SAVING;
 }
 
-export function isDraftOrUnsaved<T extends CommentBasics>(
-  x: T | DraftInfo | UnsavedInfo | undefined
-): x is UnsavedInfo | DraftInfo {
-  return isDraft(x) || isUnsaved(x);
+export function isError<T extends Comment>(
+  x: T | DraftInfo | undefined
+): boolean {
+  return !!x && (x as DraftInfo).savingState === SavingState.ERROR;
+}
+
+/**
+ * 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
+): boolean {
+  return !!x && !!(x as DraftInfo).client_id && !(x as DraftInfo).id;
 }
 
 export interface CommentThread {
@@ -750,7 +789,7 @@
    * This can only contain at most one draft. And if so, then it is the last
    * comment in this list. This must not contain unsaved drafts.
    */
-  comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+  comments: Array<Comment>;
   /**
    * Identical to the id of the first comment. If this is undefined, then the
    * thread only contains an unsaved draft.
@@ -822,8 +861,6 @@
   source_content_type?: string;
 }
 
-export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
-
 /**
  * The ContextLine entity contains the line number and line text of a single line of the source file content..
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
@@ -1321,18 +1358,6 @@
 export type RobotCommentInput = RobotCommentInfo;
 
 /**
- * This is what human, robot and draft comments can agree upon.
- *
- * Human, robot and saved draft comments all have a required id, but unsaved
- * drafts do not. That is why the id is omitted from CommentInfo, such that it
- * can be optional in Draft, but required in CommentInfo and RobotCommentInfo.
- */
-export interface CommentBasics extends Omit<CommentInfo, 'id' | 'updated'> {
-  id?: UrlEncodedCommentId;
-  updated?: Timestamp;
-}
-
-/**
  * The RobotCommentInfo entity contains information about a robot inline comment
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-info
  */
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index b6e839d..922d779 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -52,14 +52,13 @@
     'open-fix-preview': OpenFixPreviewEvent;
     'reply-to-comment': ReplyToCommentEvent;
     // prettier-ignore
-    'reload': ReloadEvent;
+    'reload': CustomEvent<{}>;
     'remove-reviewer': RemoveReviewerEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'show-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
     'tap-item': TapItemEvent;
-    'title-change': TitleChangeEvent;
   }
 }
 
@@ -69,11 +68,12 @@
     'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
     // prettier-ignore
-    'reload': ReloadEvent;
+    'reload': CustomEvent<{}>;
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'auth-error': AuthErrorEvent;
+    'title-change': TitleChangeEvent;
   }
 }
 
@@ -186,21 +186,11 @@
 }
 export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
 
-export interface ReloadEventDetail {
-  clearPatchset: boolean;
-}
-export type ReloadEvent = CustomEvent<ReloadEventDetail>;
-
 export interface RemoveAccountEventDetail {
   account: AccountInfo;
 }
 export type RemoveAccountEvent = CustomEvent<RemoveAccountEventDetail>;
 
-export interface ReplyEventDetail {
-  message: ChangeMessage;
-}
-export type ReplyEvent = CustomEvent<ReplyEventDetail>;
-
 export interface ServerErrorEventDetail {
   request?: FetchRequest;
   response: Response;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 6ac3211..32a6867 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -338,3 +338,62 @@
   };
   return wrappedPromise;
 }
+
+export interface MockPromise<T> extends Promise<T> {
+  resolve: (value?: T) => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  reject: (reason?: any) => void;
+}
+
+export function mockPromise<T = unknown>(): MockPromise<T> {
+  let res: (value?: T) => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  let rej: (reason?: any) => void;
+  const promise: MockPromise<T> = new Promise<T | undefined>(
+    (resolve, reject) => {
+      res = resolve;
+      rej = reject;
+    }
+  ) as MockPromise<T>;
+  promise.resolve = res!;
+  promise.reject = rej!;
+  return promise;
+}
+
+// MockPromise is the established name in tests, and we don't want to rename
+// that in 50 files. But "Mock" is a bit misleading and definitely not a great
+// fit for non-test code. So let's also export under a different name.
+export type InteractivePromise<T> = MockPromise<T>;
+export const interactivePromise = mockPromise;
+
+export function timeoutPromise(timeoutMs: number): Promise<void> {
+  return new Promise<void>(resolve => {
+    setTimeout(resolve, timeoutMs);
+  });
+}
+
+export async function waitUntil(
+  predicate: (() => boolean) | (() => Promise<boolean>),
+  message = 'The waitUntil() predicate is still false after 1000 ms.',
+  timeout_ms = 1000
+): Promise<void> {
+  if (await predicate()) return Promise.resolve();
+  const start = Date.now();
+  let sleep = 10;
+  const error = new Error(message);
+  return new Promise((resolve, reject) => {
+    const waiter = async () => {
+      if (await predicate()) {
+        resolve();
+        return;
+      }
+      if (Date.now() - start >= timeout_ms) {
+        reject(error);
+        return;
+      }
+      setTimeout(waiter, sleep);
+      sleep *= 2;
+    };
+    waiter();
+  });
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index ee4f73a..afc16d3 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -7,9 +7,45 @@
 import {SinonFakeTimers} from 'sinon';
 import '../test/common-test-setup';
 import {mockPromise, waitEventLoop, waitUntil} from '../test/test-utils';
-import {asyncForeach, debounceP, DelayedTask} from './async-util';
+import {
+  asyncForeach,
+  debounceP,
+  DelayedTask,
+  interactivePromise,
+  timeoutPromise,
+} from './async-util';
 
 suite('async-util tests', () => {
+  suite('interactivePromise', () => {
+    test('simple test', async () => {
+      let resolved = false;
+      const promise = interactivePromise();
+      promise.then(() => (resolved = true));
+      assert.isFalse(resolved);
+      promise.resolve();
+      await promise;
+      assert.isTrue(resolved);
+    });
+  });
+
+  suite('timeoutPromise', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+    });
+    test('simple test', async () => {
+      let resolved = false;
+      const promise = timeoutPromise(1000);
+      promise.then(() => (resolved = true));
+      assert.isFalse(resolved);
+      clock.tick(999);
+      assert.isFalse(resolved);
+      clock.tick(1);
+      await promise;
+      assert.isTrue(resolved);
+    });
+  });
+
   suite('asyncForeach', () => {
     test('loops over each item', async () => {
       const fn = sinon.stub().resolves();
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 5dda8d6..7de5e7e 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,10 +16,10 @@
 import {ParsedChangeInfo} from '../types/types';
 import {getUserId, isServiceUser} from './account-util';
 
-// This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
-  mergeable: boolean; // This can be wrong! See WARNING above
-  submitEnabled: boolean; // This can be wrong! See WARNING above
+  mergeable: boolean;
+  /** Is there a reverting change and if so, what status has it? */
+  revertingChangeStatus?: ChangeStatus;
 }
 
 export const ChangeDiffType = {
@@ -158,6 +158,12 @@
 ): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
+    if (options?.revertingChangeStatus === ChangeStatus.MERGED) {
+      return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED];
+    }
+    if (options?.revertingChangeStatus !== undefined) {
+      return [ChangeStates.MERGED, ChangeStates.REVERT_CREATED];
+    }
     return [ChangeStates.MERGED];
   }
   if (change.status === ChangeStatus.ABANDONED) {
@@ -182,11 +188,9 @@
     return states;
   }
 
-  // If no missing requirements, either active or ready to submit.
-  if (change.submittable && options.submitEnabled) {
+  if (change.submittable) {
     states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
-    // Otherwise it is active.
     states.push(ChangeStates.ACTIVE);
   }
   return states;
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index ccaa1db..1782d80 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -66,29 +66,24 @@
     assert.deepEqual(statuses, []);
 
     change.submittable = false;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
 
-    // With no missing labels but no submitEnabled option.
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
-    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.mergeable = false;
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
 
     change.mergeable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
   });
 
@@ -129,6 +124,30 @@
     assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
   });
 
+  test('Merged and Reverted status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+    };
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+    assert.deepEqual(
+      changeStatuses(change, {
+        revertingChangeStatus: ChangeStatus.NEW,
+        mergeable: true,
+      }),
+      [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]
+    );
+    assert.deepEqual(
+      changeStatuses(change, {
+        revertingChangeStatus: ChangeStatus.MERGED,
+        mergeable: true,
+      }),
+      [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]
+    );
+  });
+
   test('Abandoned status', () => {
     const change = {
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index e2fcd60..ee1a44c 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -4,10 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
-  CommentBasics,
   CommentInfo,
   PatchSetNum,
-  Timestamp,
   UrlEncodedCommentId,
   PatchRange,
   PARENT,
@@ -23,13 +21,13 @@
   CommentThread,
   DraftInfo,
   ChangeMessage,
-  UnsavedInfo,
   isRobot,
   isDraft,
-  isDraftOrUnsaved,
   Comment,
-  isUnsaved,
   CommentIdToCommentThreadMap,
+  SavingState,
+  NewDraftInfo,
+  isNew,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -37,11 +35,7 @@
 import {DiffInfo} from '../types/diff';
 import {FormattedReviewerUpdateInfo} from '../types/types';
 import {extractMentionedUsers} from './account-util';
-
-interface SortableComment {
-  updated: Timestamp;
-  id: UrlEncodedCommentId;
-}
+import {assertIsDefined, uuid} from './common-util';
 
 export function isFormattedReviewerUpdate(
   message: ChangeMessage
@@ -56,57 +50,89 @@
 export const PATCH_SET_PREFIX_PATTERN =
   /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 
-export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+/**
+ * We need a way to uniquely identify drafts. That is easy for all drafts that
+ * were already known to the backend at the time of change page load: They will
+ * have an `id` that we can use.
+ *
+ * For newly created drafts we start by setting a `client_id`, so that we can
+ * identify the draft even, if no `id` is available yet.
+ *
+ * If a comment with a `client_id` gets saved, then id gets an `id`, but we have
+ * to keep using the `client_id`, because that is what the UI is already using,
+ * e.g. in `repeat()` directives.
+ */
+export function id(comment: Comment): UrlEncodedCommentId {
+  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;
+}
+
+export function sortComments<T extends Comment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
+    const n1 = isNew(c1);
+    const n2 = isNew(c2);
+    if (n1 !== n2) return n1 ? 1 : -1;
+
     const d1 = isDraft(c1);
     const d2 = isDraft(c2);
     if (d1 !== d2) return d1 ? 1 : -1;
 
-    const date1 = parseDate(c1.updated);
-    const date2 = parseDate(c2.updated);
-    const dateDiff = date1.valueOf() - date2.valueOf();
-    if (dateDiff !== 0) return dateDiff;
+    if (c1.updated && c2.updated) {
+      const date1 = parseDate(c1.updated);
+      const date2 = parseDate(c2.updated);
+      const dateDiff = date1.valueOf() - date2.valueOf();
+      if (dateDiff !== 0) return dateDiff;
+    }
 
-    const id1 = c1.id;
-    const id2 = c2.id;
+    const id1 = id(c1);
+    const id2 = id(c2);
     return id1.localeCompare(id2);
   });
 }
 
-export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
-  return {
-    path: thread.path,
-    patch_set: thread.patchNum,
-    side: thread.commentSide ?? CommentSide.REVISION,
-    line: typeof thread.line === 'number' ? thread.line : undefined,
-    range: thread.range,
-    parent: thread.mergeParentNum,
-    message: '',
-    unresolved: true,
-    __unsaved: true,
+export function 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 createPatchsetLevelUnsavedDraft(
+export function createNewPatchsetLevel(
   patchNum?: PatchSetNumber,
   message?: string,
   unresolved?: boolean
-): UnsavedInfo {
+): DraftInfo {
   return {
+    ...createNew(message, unresolved),
     patch_set: patchNum,
-    message,
-    unresolved,
     path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-    __unsaved: true,
   };
 }
 
-export function createUnsavedReply(
+export function createNewReply(
   replyingTo: CommentInfo,
   message: string,
   unresolved: boolean
-): UnsavedInfo {
+): DraftInfo {
   return {
+    ...createNew(message, unresolved),
     path: replyingTo.path,
     patch_set: replyingTo.patch_set,
     side: replyingTo.side,
@@ -114,13 +140,10 @@
     range: replyingTo.range,
     parent: replyingTo.parent,
     in_reply_to: replyingTo.id,
-    message,
-    unresolved,
-    __unsaved: true,
   };
 }
 
-export function createCommentThreads(comments: CommentInfo[]) {
+export function createCommentThreads(comments: Comment[]) {
   const sortedComments = sortComments(comments);
   const threads: CommentThread[] = [];
   const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -130,7 +153,7 @@
       const thread = idThreadMap[comment.in_reply_to];
       if (thread) {
         thread.comments.push(comment);
-        if (comment.id) idThreadMap[comment.id] = thread;
+        if (id(comment)) idThreadMap[id(comment)] = thread;
         continue;
       }
     }
@@ -147,13 +170,13 @@
       path: comment.path,
       line: comment.line,
       range: comment.range,
-      rootId: comment.id,
+      rootId: id(comment),
     };
     if (!comment.line && !comment.range) {
       newThread.line = 'FILE';
     }
     threads.push(newThread);
-    if (comment.id) idThreadMap[comment.id] = newThread;
+    if (id(comment)) idThreadMap[id(comment)] = newThread;
   }
   return threads;
 }
@@ -173,22 +196,24 @@
   );
 }
 
-export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+export function getLastComment(
+  thread: CommentThread
+): CommentInfo | DraftInfo | undefined {
   const len = thread.comments.length;
   return thread.comments[len - 1];
 }
 
 export function getLastPublishedComment(
   thread: CommentThread
-): CommentInfo | undefined {
-  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+): CommentInfo | DraftInfo | undefined {
+  const publishedComments = thread.comments.filter(c => !isDraft(c));
   const len = publishedComments.length;
   return publishedComments[len - 1];
 }
 
 export function getFirstComment(
   thread: CommentThread
-): CommentInfo | undefined {
+): CommentInfo | DraftInfo | undefined {
   return thread.comments[0];
 }
 
@@ -213,6 +238,14 @@
   return isDraft(getLastComment(thread));
 }
 
+/**
+ * Returns true, if the thread consists only of one comment that has not yet
+ * been saved to the backend.
+ */
+export function isNewThread(thread: CommentThread): boolean {
+  return isNew(getFirstComment(thread));
+}
+
 export function isMentionedThread(
   thread: CommentThread,
   account?: AccountInfo
@@ -295,10 +328,7 @@
 /**
  * Whether the given comment should be included in the given patch range.
  */
-export function isInPatchRange(
-  comment: CommentBasics,
-  range: PatchRange
-): boolean {
+export function isInPatchRange(comment: Comment, range: PatchRange): boolean {
   return (
     isInBaseOfPatchRange(comment, range) ||
     isInRevisionOfPatchRange(comment, range)
@@ -405,8 +435,8 @@
 }
 
 /**
- * Add __draft:true to all drafts returned from server so that they can be told
- * apart from published comments easily.
+ * Add `savingState: SavingState.OK` to all drafts returned from server so that
+ * they can be told apart from published comments easily.
  */
 export function addDraftProp(
   draftsByPath: {[path: string]: CommentInfo[]} = {}
@@ -414,13 +444,13 @@
   const updated: {[path: string]: DraftInfo[]} = {};
   for (const filePath of Object.keys(draftsByPath)) {
     updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
-      return {...draft, __draft: true};
+      return {...draft, savingState: SavingState.OK};
     });
   }
   return updated;
 }
 
-export function reportingDetails(comment: CommentBasics) {
+export function reportingDetails(comment: Comment) {
   return {
     id: comment?.id,
     message_length: comment?.message?.trim().length,
@@ -428,7 +458,7 @@
     unresolved: comment?.unresolved,
     path_length: comment?.path?.length,
     line: comment?.range?.start_line ?? comment?.line,
-    unsaved: isUnsaved(comment),
+    unsaved: isNew(comment),
   };
 }
 
@@ -446,13 +476,17 @@
   return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
 }
 
+export function getUserSuggestionFromString(content: string) {
+  const start =
+    content.indexOf(USER_SUGGESTION_START_PATTERN) +
+    USER_SUGGESTION_START_PATTERN.length;
+  const end = content.indexOf('\n```', start);
+  return content.substring(start, end);
+}
+
 export function getUserSuggestion(comment: Comment) {
   if (!comment.message) return;
-  const start =
-    comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
-    USER_SUGGESTION_START_PATTERN.length;
-  const end = comment.message.indexOf('\n```', start);
-  return comment.message.substring(start, end);
+  return getUserSuggestionFromString(comment.message);
 }
 
 export function getContentInCommentRange(
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 0a8aa82..7bf0c1e 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -16,6 +16,8 @@
   createUserFixSuggestion,
   PROVIDED_FIX_ID,
   getMentionedThreads,
+  isNewThread,
+  createNew,
 } from './comment-util';
 import {
   createAccountWithEmail,
@@ -24,6 +26,9 @@
 } from '../test/test-data-generators';
 import {CommentSide} from '../constants/constants';
 import {
+  Comment,
+  DraftInfo,
+  SavingState,
   PARENT,
   RevisionPatchSetNum,
   Timestamp,
@@ -69,6 +74,17 @@
     );
   });
 
+  test('isNewThread', () => {
+    let thread = createCommentThread([createComment()]);
+    assert.isFalse(isNewThread(thread));
+
+    thread = createCommentThread([createComment(), createNew()]);
+    assert.isFalse(isNewThread(thread));
+
+    thread = createCommentThread([createNew()]);
+    assert.isTrue(isNewThread(thread));
+  });
+
   suite('getPatchRangeForCommentUrl', () => {
     test('comment created with side=PARENT does not navigate to latest ps', () => {
       const comment = {
@@ -127,13 +143,13 @@
   });
 
   test('comments sorting', () => {
-    const comments = [
+    const comments: Comment[] = [
       {
         id: 'new_draft' as UrlEncodedCommentId,
         message: 'i do not like either of you',
-        __draft: true,
+        savingState: SavingState.OK,
         updated: '2015-12-20 15:01:20.396000000' as Timestamp,
-      },
+      } as DraftInfo,
       {
         id: 'sallys_confession' as UrlEncodedCommentId,
         message: 'i like you, jack',
@@ -145,7 +161,7 @@
         message: 'i like you, too',
         updated: '2015-12-24 15:01:20.396000000' as Timestamp,
         line: 1,
-        in_reply_to: 'sallys_confession',
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
       },
     ];
     const sortedComments = sortComments(comments);
@@ -156,7 +172,7 @@
 
   suite('createCommentThreads', () => {
     test('creates threads from individual comments', () => {
-      const comments = [
+      const comments: Comment[] = [
         {
           id: 'sallys_confession' as UrlEncodedCommentId,
           message: 'i like you, jack',
@@ -177,7 +193,7 @@
         {
           id: 'new_draft' as UrlEncodedCommentId,
           message: 'i do not like either of you' as UrlEncodedCommentId,
-          __draft: true,
+          savingState: SavingState.OK,
           updated: '2015-12-20 15:01:20.396000000' as Timestamp,
           patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 183d167..c89bdff 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -175,3 +175,10 @@
   await navigator.clipboard.writeText(text);
   fireAlert(document, `${copyTargetName ?? text} was copied to clipboard`);
 }
+
+/**
+ * Produces strings such as `y364b4tm28n`.
+ */
+export function uuid() {
+  return Math.random().toString(36).substring(2);
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 3704557..af545e7 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -95,8 +95,8 @@
   fire(document, 'network-error', {error});
 }
 
-export function fireTitleChange(target: EventTarget, title: string) {
-  fire(target, 'title-change', {title});
+export function fireTitleChange(title: string) {
+  fire(document, 'title-change', {title});
 }
 
 // TODO(milutin) - remove once new gr-dialog will do it out of the box
@@ -122,8 +122,8 @@
   fire(target, 'show-tab', detail);
 }
 
-export function fireReload(target: EventTarget, clearPatchset?: boolean) {
-  fire(target, 'reload', {clearPatchset: !!clearPatchset});
+export function fireReload(target: EventTarget) {
+  fire(target, 'reload', {});
 }
 
 export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
new file mode 100644
index 0000000..22a5e4d
--- /dev/null
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getRevertCreatedChangeIds} from './message-util';
+import {assert} from '@open-wc/testing';
+import {MessageTag} from '../constants/constants';
+import {ChangeId, ReviewInputTag} from '../api/rest-api';
+import {createChangeMessage} from '../test/test-data-generators';
+
+suite('message-util tests', () => {
+  test('getRevertCreatedChangeIds', () => {
+    const messages = [
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as 123',
+        tag: MessageTag.TAG_REVERT as ReviewInputTag,
+      },
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as xyz',
+        tag: MessageTag.TAG_REVERT as ReviewInputTag,
+      },
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as abc',
+        tag: undefined,
+      },
+    ];
+
+    assert.deepEqual(getRevertCreatedChangeIds(messages), [
+      '123' as ChangeId,
+      'xyz' as ChangeId,
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 7e16ad9..7f3b6eb 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -35,11 +35,6 @@
   wip?: boolean;
 }
 
-interface PatchRange {
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-}
-
 /**
  * Whether the given patch is a numbered parent of a merge (i.e. a negative
  * number).
@@ -294,10 +289,6 @@
   return allPatchSets[0].num === EDIT;
 }
 
-export function hasEditPatchsetLoaded(patchRange: PatchRange) {
-  return patchRange.patchNum === EDIT;
-}
-
 /**
  * @param revisions A sorted array of revisions.
  *
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 4fa0e63..6ceaa4f 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -149,3 +149,9 @@
 export function generateAbsoluteUrl(url: string) {
   return new URL(url, window.location.href).toString();
 }
+
+export function sameOrigin(href: string) {
+  if (!href) return false;
+  const url = new URL(href, window.location.origin);
+  return url.origin === window.location.origin;
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index c82cd70..e2ff837 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -14,6 +14,7 @@
   toSearchParams,
   getPatchRangeExpression,
   PatchRangeParams,
+  sameOrigin,
 } from './url-util';
 import {assert} from '@open-wc/testing';
 
@@ -69,6 +70,12 @@
     });
   });
 
+  test('sameOrigin', () => {
+    assert.isTrue(sameOrigin('/asdf'));
+    assert.isTrue(sameOrigin(window.location.origin + '/asdf'));
+    assert.isFalse(sameOrigin('http://www.goole.com/asdf'));
+  });
+
   test('toPathname', () => {
     assert.equal(toPathname('asdf'), 'asdf');
     assert.equal(toPathname('asdf?qwer=zxcv'), 'asdf');
diff --git a/proto/cache.proto b/proto/cache.proto
index 7063ee5..79bfa9e 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 28
+// Next ID: 29
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -142,6 +142,8 @@
 
   repeated string hashtag = 5;
 
+  map<string, string> custom_keyed_values = 28;
+
   repeated devtools.gerritcodereview.PatchSet patch_set = 6;
 
   repeated devtools.gerritcodereview.PatchSetApproval approval = 7;
diff --git a/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html b/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
index 593ea45..08c890d 100644
--- a/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
+++ b/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
@@ -43,6 +43,7 @@
             <input name="rememberme" id="f_remember"
                    type="checkbox"
                    value="1"
+                   checked="checked"
                    tabindex="3" />
             <label for="f_remember">Remember me</label>
           </td>
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index 319db05..8958ea3 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -48,9 +48,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKeys}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index c356a95..cb5b224 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -47,9 +47,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeys}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
index 0957dc6..46bfc7e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKey.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -47,9 +47,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKey}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
index fea6785..539688e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -45,9 +45,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeyFingerprints}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/Email.soy b/resources/com/google/gerrit/server/mail/Email.soy
new file mode 100644
index 0000000..9afea72
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Email.soy
@@ -0,0 +1,27 @@
+/**
+ * Copyright (C) 2023 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template.Email}
+
+/**
+ * The .Email template defines the structure of the content in the email.
+ */
+{template Email kind="text"}
+  {@param body: string}
+  {@param footer: string}
+  {$body}
+  {$footer}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/EmailHtml.soy b/resources/com/google/gerrit/server/mail/EmailHtml.soy
new file mode 100644
index 0000000..5b5ea63
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/EmailHtml.soy
@@ -0,0 +1,38 @@
+/**
+ * Copyright (C) 2023 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template.EmailHtml}
+
+/**
+ * The .EmailHtml template defines the structure of the content in the email.
+ */
+{template EmailHtml}
+  {@param styles: css}
+  {@param body: html}
+  {@param footer: html}
+  <!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        {$styles}
+      </style>
+    </head>
+    <body>
+      {$body}
+      {$footer}
+    </body>
+  </html>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
index 49fbccb..3efa8be 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -33,7 +33,7 @@
 
   You can also manage your HTTP password by visiting
   {\n}
-  {$email.gerritUrl}#/settings/http-password
+  {$email.httpPasswordSettingsUrl}
   {\n}
   {if $email.userNameEmail}
     (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
index 3f88a6f..ee033cd 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -30,7 +30,7 @@
 
   <p>
     You can also manage your HTTP password by following{sp}
-    <a href="{$email.gerritUrl}#/settings/http-password">this link</a>
+    <a href="{$email.httpPasswordSettingsUrl}">this link</a>
     {sp}
     {if $email.userNameEmail}
       (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 273f52f..cd38742 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -34,7 +34,7 @@
 
   {\n}
 
-  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+  {$email.emailRegistrationLink}{\n}
 
   {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 7d6cd23..20f9999 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -31,7 +31,7 @@
 
   <p>
 
-    {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}
+    {$email.emailRegistrationLink}
   </p>
   <p>
     If you have received this mail in error, you do not need to take any
diff --git a/tools/BUILD b/tools/BUILD
index e25dcc5..e05a705 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -76,6 +76,7 @@
         "-Xep:BadImport:ERROR",
         "-Xep:BadInstanceof:ERROR",
         "-Xep:BadShiftAmount:ERROR",
+        "-Xep:BanJNDI:WARN",
         "-Xep:BanSerializableRead:ERROR",
         "-Xep:BigDecimalEquals:ERROR",
         "-Xep:BigDecimalLiteralDouble:ERROR",
@@ -187,6 +188,7 @@
         "-Xep:ImmutableAnnotationChecker:ERROR",
         "-Xep:ImmutableEnumChecker:ERROR",
         "-Xep:ImmutableModification:ERROR",
+        "-Xep:ImpossibleNullComparison:WARN",
         "-Xep:Incomparable:ERROR",
         "-Xep:IncompatibleArgumentType:ERROR",
         "-Xep:IncompatibleModifiers:ERROR",
@@ -255,7 +257,7 @@
         "-Xep:JodaWithDurationAddedLong:ERROR",
         "-Xep:LiteByteStringUtf8:ERROR",
         "-Xep:LiteEnumValueOf:ERROR",
-        "-Xep:LiteProtoToString:ERROR",
+        "-Xep:LiteProtoToString:WARN",
         "-Xep:LocalDateTemporalAmount:ERROR",
         "-Xep:LockNotBeforeTry:ERROR",
         "-Xep:LockOnBoxedPrimitive:ERROR",
@@ -287,7 +289,7 @@
         "-Xep:MultipleParallelOrSequentialCalls:ERROR",
         "-Xep:MultipleUnaryOperatorsInMethodCall:ERROR",
         "-Xep:MustBeClosedChecker:ERROR",
-        "-Xep:MutableConstantField:ERROR",
+        "-Xep:MutableConstantField:WARN",
         "-Xep:MutablePublicArray:ERROR",
         "-Xep:NCopiesOfChar:ERROR",
         "-Xep:NarrowingCompoundAssignment:ERROR",
@@ -340,7 +342,7 @@
         "-Xep:ProtoTimestampGetSecondsGetNano:ERROR",
         "-Xep:ProtoTruthMixedDescriptors:ERROR",
         "-Xep:ProtocolBufferOrdinal:ERROR",
-        "-Xep:ProvidesMethodOutsideOfModule:ERROR",
+        "-Xep:ProvidesMethodOutsideOfModule:WARN",
         "-Xep:RandomCast:ERROR",
         "-Xep:RandomModInteger:ERROR",
         "-Xep:ReachabilityFenceUsage:ERROR",
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 7f26ef3..a03ccf0 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -171,24 +171,24 @@
         sha1 = GUAVA_TESTLIB_BIN_SHA1,
     )
 
-    GUICE_VERS = "5.0.1"
+    GUICE_VERS = "5.1.0"
 
     maven_jar(
         name = "guice-library",
         artifact = "com.google.inject:guice:" + GUICE_VERS,
-        sha1 = "0dae7556b441cada2b4f0a2314eb68e1ff423429",
+        sha1 = "da25056c694c54ba16e78e4fc35f17fc60f0d1b4",
     )
 
     maven_jar(
         name = "guice-assistedinject",
         artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-        sha1 = "62e02f2aceb7d90ba354584dacc018c1e94ff01c",
+        sha1 = "58a8956f00d6939978d7da735f393d7af7db5c02",
     )
 
     maven_jar(
         name = "guice-servlet",
         artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-        sha1 = "f527009d51f172a2e6937bfb55fcb827e2e2386b",
+        sha1 = "cb89ddec4246a469698a3461e69de1f245016c5d",
     )
 
     # Keep this version of Soy synchronized with the version used in Gitiles.
@@ -243,36 +243,36 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "7.7.3"
+    LUCENE_VERS = "8.11.2"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
+        sha1 = "57438c3f31e0e440de149294890eee88e030ea6d",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
+        sha1 = "07a74c5c2dd082b08c644a9016bc6ff66c8f27cc",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
+        sha1 = "a5d0f0db405d607cc13265819b8d2ef0c81c0819",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
+        sha1 = "9c7204f923465a96a20ac9e49cdca0cfcde64851",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
+        sha1 = "1886e3a27a8d4a73eb8fad54ea93a160b099bc60",
     )
 
     # JGit's transitive dependencies