Merge "Respect .gitattributes diff overrides in diff operations"
diff --git a/.agents/rules.json b/.agents/rules.json
new file mode 100644
index 0000000..bcd4b27
--- /dev/null
+++ b/.agents/rules.json
@@ -0,0 +1,7 @@
+{
+  "entries": [
+    {
+      "path": ".agents/rules/upload_rationale.md"
+    }
+  ]
+}
diff --git a/.agents/rules/upload_rationale.md b/.agents/rules/upload_rationale.md
new file mode 100644
index 0000000..f7a13a4
--- /dev/null
+++ b/.agents/rules/upload_rationale.md
@@ -0,0 +1,18 @@
+---
+trigger: always_on
+description: Mandate uploading agent rationale for Gerrit changes
+---
+
+## Gerrit Rationale Upload Policy
+
+Whenever you create a new Gerrit change or upload a patchset (via `git push`,
+`hg upload`, `g4 upload`, or any other VCS command), you **MUST** immediately
+upload AI rationales for that patchset.
+
+Follow this two-step workflow:
+
+1.  **Push Code**: Execute your VCS push/upload command so the new patchset is
+    created on Gerrit.
+2.  **Upload Rationale**: Read the `gerrit` skill (specifically the
+    `upload-rationale` section) and follow its instructions to construct and
+    upload the complete set of rationales for the patchset.
diff --git a/.agents/skills.json b/.agents/skills.json
new file mode 100644
index 0000000..88387c1
--- /dev/null
+++ b/.agents/skills.json
@@ -0,0 +1,7 @@
+{
+  "entries": [
+    {
+      "path": "/google/src/files/head/depot/google3/learning/gemini/agents/skills/gerrit"
+    }
+  ]
+}
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
new file mode 100755
index 0000000..b1e9051
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "npm", pnpm_lock = "@@//:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+package.json=259674664
+pnpm-lock.yaml=640391753
+pnpm-workspace.yaml=-408937369
+yarn.lock=742513459
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_NDEzOTg2ODE= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_NDEzOTg2ODE=
new file mode 100755
index 0000000..51238c9
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_NDEzOTg2ODE=
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "tools_npm", pnpm_lock = "@@//tools/node_tools:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+tools/node_tools/package.json=899646160
+tools/node_tools/pnpm-lock.yaml=-1121876554
+tools/node_tools/pnpm-workspace.yaml=-408937369
+tools/node_tools/yarn.lock=1865795164
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4NDE3MDEy b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4NDE3MDEy
new file mode 100755
index 0000000..3612935
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4NDE3MDEy
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "ui_npm", pnpm_lock = "@@//polygerrit-ui/app:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+polygerrit-ui/app/package.json=-844837576
+polygerrit-ui/app/pnpm-lock.yaml=1583889522
+polygerrit-ui/app/pnpm-workspace.yaml=1396467527
+polygerrit-ui/app/yarn.lock=-909422203
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4OTg1MjQ0 b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4OTg1MjQ0
new file mode 100755
index 0000000..33edcc5
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4OTg1MjQ0
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "ui_dev_npm", pnpm_lock = "@@//polygerrit-ui:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+polygerrit-ui/package.json=-860495337
+polygerrit-ui/pnpm-lock.yaml=201412925
+polygerrit-ui/pnpm-workspace.yaml=-408937369
+polygerrit-ui/yarn.lock=1276229348
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
new file mode 100755
index 0000000..23457d5
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "plugins_npm", pnpm_lock = "@@//plugins:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+plugins/package.json=1633560207
+plugins/pnpm-lock.yaml=-77521523
+plugins/pnpm-workspace.yaml=-408937369
+plugins/yarn.lock=-2081175573
diff --git a/.bazelrc b/.bazelrc
index b864a3b..f5d330f 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -4,6 +4,7 @@
 common --incompatible_disallow_struct_provider_syntax=false
 common --incompatible_disallow_empty_glob=false
 common --@aspect_rules_ts//ts:default_to_tsc_transpiler
+common --repo_env=ASPECT_TOOLS_TELEMETRY=-all
 
 build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
diff --git a/.gitignore b/.gitignore
index 2d146c8..f42cfc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,9 +10,9 @@
 .DS_Store
 CLAUDE.md
 js-to-ts.sh
-/.aspect/
 /.apt_generated
 /.apt_generated_tests
+/.eslintcache
 /.bazel_path
 /.classpath
 /.factorypath
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..ca5c350
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24.18.0
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 65f05b1..ceca34b 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -8,20 +8,39 @@
 --
 _ssh_ -p <port> <host> _gerrit show-caches_
   [--show-jvm]
+  [--include-diskstats]
+  [--cache <NAME> ...]
 --
 
 == DESCRIPTION
 Display statistics about the size and hit ratio of in-memory caches.
 
+By default, the command displays statistics for all registered caches.
+Because collecting data for numerous large caches on a busy server
+can cause delays, the --cache option can be used (one or more times)
+to limit the output to specific caches.
+
 == OPTIONS
 --show-jvm::
 	List the name and version of the Java virtual machine, host
 	operating system, and other details about the environment
 	that Gerrit Code Review is running in.
 
+--include-diskstats::
+	Include disk stat collection for persistent caches.
+
 --show-threads::
 	Show detailed counts for Gerrit specific threads.
 
+--cache <NAME>::
+	Show statistics only for the cache called <NAME>. May be
+	supplied more than once to display multiple caches in a single
+	command execution. Cache names are matched case-insensitively
+	against the full registered cache name (including any plugin
+	prefix). Unknown cache names are silently ignored. The
+	summary sections (SSH, tasks, memory, threads, JVM) are not
+	affected by this option.
+
 --width::
 -w::
 	Width of the output table.
@@ -84,6 +103,12 @@
 Threads: 4 CPUs available, 371 threads
 ----
 
+Show statistics for only the "accounts" and "projects" caches:
+
+----
+$ ssh -p 29418 review.example.com gerrit show-caches --cache accounts --cache projects
+----
+
 == SEE ALSO
 
 * link:cmd-flush-caches.html[gerrit flush-caches]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 42b4c10d..d8c1f6b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -430,6 +430,26 @@
 HTTP header to trust the username from, or unset to select HTTP basic
 authentication.  Only used if `auth.type` is set to `HTTP`.
 
+[[auth.httpTrustedProxyNetworks]]auth.httpTrustedProxyNetworks::
++
+List of IPv4 networks CIDRs (e.g., `192.168.0.0/16`) that are trusted proxies for receiving
+HTTP header authentication.  Specify multiple networks by adding multiple entries.
+It applies only when `auth.type` is set to `HTTP` or `HTTP_LDAP`. The trusted-proxy
+remote address must be IPv4; if Gerrit is reached over IPv6, header-based
+authentication is rejected.
++
+[NOTE]
+====
+Accepting a trusted authentication HTTP header involves a leap of faith in the security
+of the incoming HTTP traffic. By default, the only secure way to ensure that all the
+traffic is coming from a trusted authentication proxy is via firewall rules or using
+a local proxy on the loopback interface. When using a remote proxy, the security of
+the connection between the proxy and Gerrit is in doubt, and this can be mitigated by
+the use of `auth.httpTrustedProxyNetworks`.
+====
++
+Default: accept any incoming IPs.
+
 [[auth.httpDisplaynameHeader]]auth.httpDisplaynameHeader::
 +
 HTTP header to retrieve the user's display name from.  Only used if `auth.type`
@@ -912,6 +932,32 @@
 Valid values are 0, and positive integers. Setting this to 0 will
 cause the filter to never be rebuilt.
 +
+[[cache.startupThreads]]cache.startupThreads::
++
+Number of threads used to open persistent caches in parallel during startup.
+Each persistent cache is backed by its own H2 database file and builds a
+BloomFilter by reading all keys from disk when it is opened. With the default
+of 1, caches are opened sequentially. Increasing this value allows the
+BloomFilter build phase to run concurrently across caches.
++
+Setting this value greater than 1 creates threads in a dedicated pool, separate
+from the H2 thread pool (`DiskCache-Store`) that handles cache updates.
++
+Default is 1.
+
+[[cache.preWarmForBloomFilter]]cache.preWarmForBloomFilter::
++
+When enabled, each persistent cache reads its H2 database file into the OS
+page cache before building its BloomFilter. The BloomFilter build requires
+a full table scan of all keys which involves reading close to the entire file
+in a scattered fashion. Doing such a read tends to not perform well I/O wise as
+it generally cannot take advantage of operating system level file system
+readahead. Reading the file sequentially up front tends to take advantage of
+readahead to fully populate the file system caches which then can make scattered
+reads much faster.
++
+Default is true.
+
 [[cache.openFiles]]cache.openFiles::
 +
 The number of file descriptors to add to the limit set by the Gerrit daemon.
@@ -5080,6 +5126,7 @@
 [[plugins.loadPriority]]plugins.loadPriority::
 +
 List of `pluginName`s required to have a specific loading order during Gerrit startup.
+Plugins will be unloaded in reverse order.
 +
 Each entry should contain a plugin name defined in the `MANIFEST.MF` under
 `Gerrit-PluginName` or a plugin JAR file name. During the Gerrit startup
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 49ec3f4..c440eb3 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -226,7 +226,7 @@
 
 $change.shortOriginalSubject::
 +
-The original subject limited to 72 characters, with an ellipsis if it exceeds
+The original subject limited to 72 characters, with an ellipsis if it exceeds.
 that.
 
 $change.sizeBucket::
@@ -251,7 +251,7 @@
 
 $instanceAndProjectName::
 +
-The Gerrit instance name, followed by the short project name
+The Gerrit instance name, followed by the short project name.
 
 $addInstanceNameInSubject::
 +
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index d8bfb77..bf26fc2 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -43,6 +43,35 @@
 Filters on a folder, they will be overwritten the next time you run
 `tools/eclipse/project.py`.
 
+=== JGit servlet-facing sources
+
+Gerrit consumes JGit from source through `modules/jgit`, but JGit master uses
+`jakarta.servlet` in servlet-facing modules while Gerrit still runs on Jetty 12
+EE8 and `javax.servlet`.
+
+For those modules, Gerrit uses JGit-provided Bazel EE8 bridge targets:
+
+----
+  @jgit//org.eclipse.jgit.http.server.ee8:jgit-servlet-ee8
+  @jgit//org.eclipse.jgit.lfs.server.ee8:jgit-lfs-server-ee8
+----
+
+The generated artifacts keep the original JGit Java packages and source line
+numbers. They only rewrite servlet imports from `jakarta.servlet` to
+`javax.servlet`, so Gerrit Java code keeps its existing imports.
+
+The Eclipse project generator does not import
+`modules/jgit/org.eclipse.jgit.http.server/src` as a source folder. Instead, it
+attaches the generated EE8 source jar when the generated servlet jar is on the
+classpath. Put breakpoints in the generated source attachment when debugging
+runtime HTTP servlet classes.
+
+The same source attachment rule applies to
+`org.eclipse.jgit.lfs.server.ee8:jgit-lfs-server-ee8` for plugin consumers such
+as the Gerrit LFS plugin. Core Gerrit does not depend on that jar today, but the
+Eclipse generator is prepared to attach its generated source jar when a plugin
+classpath includes it.
+
 === Eclipse project on MacOS
 
 By default, bazel uses `/private/var/tmp` as the
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index be4196c..a4b7776 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -86,6 +86,45 @@
 advantage of this is that project files don't need to be excluded from version
 control.
 
+=== JGit servlet-facing sources
+
+Gerrit consumes JGit from source through `modules/jgit`, but JGit master uses
+`jakarta.servlet` in servlet-facing modules while Gerrit still runs on Jetty 12
+EE8 and `javax.servlet`.
+
+For those modules, Gerrit uses JGit-provided Bazel EE8 bridge targets:
+
+----
+  @jgit//org.eclipse.jgit.http.server.ee8:jgit-servlet-ee8
+  @jgit//org.eclipse.jgit.lfs.server.ee8:jgit-lfs-server-ee8
+----
+
+The generated artifacts keep the original JGit Java packages and source line
+numbers. They only rewrite servlet imports from `jakarta.servlet` to
+`javax.servlet`, so Gerrit Java code keeps its existing imports.
+
+Use the current JetBrains Bazel plugin (the new, actively developed one). The
+legacy "Bazel for IntelliJ" plugin does not support Bazel 9 / Bzlmod, which
+Gerrit requires. The Bazel plugin imports `libjgit-servlet-ee8.jar` together
+with its generated source jar, so the servlet classes resolve to the EE8
+`javax.servlet` sources. Put breakpoints there when debugging runtime HTTP
+servlet classes; because line numbers are preserved, breakpoints bind and step
+correctly. The same applies to `jgit-lfs-server-ee8` for plugin consumers such
+as the Gerrit LFS plugin.
+
+The canonical, editable sources stay in SCM under
+`modules/jgit/org.eclipse.jgit.http.server` (and `org.eclipse.jgit.lfs.server`).
+To change the servlet code, edit the canonical `jakarta.servlet` sources,
+rebuild, and the change flows into the generated EE8 artifacts.
+
+NOTE: The IDE may flag these classes with "Library source does not match the
+bytecode" and report unresolved `javax.servlet` symbols. This is a known
+limitation of the Bazel IntelliJ integration with generated and duplicate
+sources: the project indexes both the canonical `jakarta.servlet` source and
+the EE8 `javax.servlet` jar, which share class names. It is a static-analysis
+artifact only and does not affect debugging, which uses the class actually
+loaded at runtime.
+
 == Recommended settings
 
 === Code style
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 7da1c18..5c13aef 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -208,12 +208,20 @@
 * `caches/memory_eviction_count`: Memory eviction count.
 ** `cache_name`:
    The name of the cache.
+* `caches/memory_request_count`: Total number of lookups (hits + misses) against
+  the in-memory cache.
+** `cache_name`:
+   The name of the cache.
 * `caches/disk_cached`: Disk entries used by persistent cache.
 ** `cache_name`:
    The name of the cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
 ** `cache_name`:
    The name of the cache.
+* `caches/disk_request_count`: Total number of lookups (hits + misses) against
+  the persistent disk cache.
+** `cache_name`:
+   The name of the cache.
 * `caches/refresh_count`: The number of refreshes per cache with an indicator if
   a reload was necessary.
 ** `cache`:
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 80d20c3..ed63caa 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -68,6 +68,20 @@
 current revision displayed, an instance of
 link:rest-api-changes.html#revision-info[RevisionInfo]
 
+=== change-view-commit-aside
+The `change-view-commit-aside` extension point is located on the change view
+page, next to the commit message above the related changes list.
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
+
 === change-metadata-item
 The `change-metadata-item` extension point is located on the change view
 left panel, below the `Submit Requirements` and `Links` sections by default.
diff --git a/Documentation/release_war_jars.txt b/Documentation/release_war_jars.txt
index 953e2dc..099aae1 100644
--- a/Documentation/release_war_jars.txt
+++ b/Documentation/release_war_jars.txt
@@ -35,7 +35,7 @@
 gson
 guava-33.5.0-jre
 guava-retrying
-guice
+guice-6.0.0-classes
 guice-assistedinject
 guice-servlet
 h2
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index b86d732..f7e477d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -330,11 +330,25 @@
 
 The entries in the map are sorted by cache name.
 
+.Query Options
+[options="header",cols="1,3"]
+|===
+|Field Name          |Description
+|`include-diskstats` |If `true`, disk stat collection is included for persistent caches.
+|===
+
 .Request
 ----
   GET /config/server/caches/ HTTP/1.0
 ----
 
+To include disk stats for persistent caches:
+
+.Request
+----
+  GET /config/server/caches/?include-diskstats=true HTTP/1.0
+----
+
 .Response
 ----
   HTTP/1.1 200 OK
diff --git a/MODULE.bazel b/MODULE.bazel
index e2c4cd4..55fbaa8 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -7,16 +7,25 @@
 
 # Language rules.
 bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
-bazel_dep(name = "aspect_rules_js", version = "3.1.2")
+bazel_dep(name = "aspect_rules_js", version = "3.2.3")
 bazel_dep(name = "aspect_rules_rollup", version = "2.0.1")
-bazel_dep(name = "aspect_rules_ts", version = "3.8.8")
+bazel_dep(name = "aspect_rules_ts", version = "3.9.2")
 bazel_dep(name = "rules_android", version = "0.7.2")
 bazel_dep(name = "rules_go", version = "0.60.0")
 bazel_dep(name = "rules_java", version = "9.3.0")
-bazel_dep(name = "rules_nodejs", version = "6.7.4")
+bazel_dep(name = "rules_nodejs", version = "6.7.5")
 bazel_dep(name = "rules_python", version = "2.0.2")
 bazel_dep(name = "rules_shell", version = "0.8.0")
 
+# Pin rules_java to Gerrit's chosen version. Transitive bzlmod modules can
+# otherwise raise it through MVS, which changes the Java toolchain inputs
+# and can break custom plugin builds. When merging up, keep this override
+# but update the version to the target branch's chosen rules_java version.
+single_version_override(
+    module_name = "rules_java",
+    version = "9.3.0",
+)
+
 # Libraries / toolchains.
 bazel_dep(name = "protobuf", version = "35.0")
 
@@ -51,4 +60,3 @@
 
 # Wiring for external dependencies contributed by in-tree plugins.
 include("//plugins:external_plugin_deps.MODULE.bazel")
-
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index a497a62..7e2e9de 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -7,13 +7,11 @@
     "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0",
     "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb",
     "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16",
-    "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915",
     "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed",
     "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16",
     "https://bcr.bazel.build/modules/abseil-cpp/20250127.0/MODULE.bazel": "d1086e248cda6576862b4b3fe9ad76a214e08c189af5b42557a6e1888812c5d5",
     "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1",
     "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
-    "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/MODULE.bazel": "c43c16ca2c432566cdb78913964497259903ebe8fb7d9b57b38e9f1425b427b8",
     "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2",
     "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c",
     "https://bcr.bazel.build/modules/abseil-py/2.1.0/MODULE.bazel": "5ebe5bf853769c65707e5c28f216798f7a4b1042015e6a36e6d03094d94bec8a",
@@ -35,14 +33,14 @@
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
     "https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
-    "https://bcr.bazel.build/modules/aspect_rules_js/3.1.2/MODULE.bazel": "e3685502155d3cc65f3bf98e714f7435de67d7f8f355d63478a80197310311fc",
-    "https://bcr.bazel.build/modules/aspect_rules_js/3.1.2/source.json": "a32ab71831452b945f3f83a1b1feb9402007e600bce55ac76e15ef0c1e08b520",
+    "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/MODULE.bazel": "3a0363a5b8ec4931488dc327c577a78c1f5a725293ebffaa5d2613283701aa8a",
+    "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/source.json": "fa3187962f5fdeee0c6226792904aaafb2cf585717a264fa4393a767bffb99fe",
     "https://bcr.bazel.build/modules/aspect_rules_rollup/2.0.1/MODULE.bazel": "296e3a053658c2af989ba9bd62a205e6d1fa84bdd6dd5249196546e6b84770ec",
     "https://bcr.bazel.build/modules/aspect_rules_rollup/2.0.1/source.json": "2fe8ac1ccb4de74bf884761e070010280b272d94e3997205b361b91c75409726",
-    "https://bcr.bazel.build/modules/aspect_rules_ts/3.8.8/MODULE.bazel": "b52b929a948438665809d49af610f58d1b14f63d6d21ab748f47b6050be4c1f6",
-    "https://bcr.bazel.build/modules/aspect_rules_ts/3.8.8/source.json": "5414530b761a45ab7ca6c49f0a2a9cf8dc0da772f5037cf05ca18aaa64bb1b19",
-    "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183",
-    "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/source.json": "605086bbc197743a0d360f7ddc550a1d4dfa0441bc807236e17170f636153348",
+    "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/MODULE.bazel": "feeb6c45b69c995eca3e5ca5872658c80df658022e01044eca00cf472bb89142",
+    "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/source.json": "cf3075502f798f71a9c5707a7684eaf0b86da11ed440fd90ea34385dc297676a",
+    "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/MODULE.bazel": "f31aa84151d31e98cffd43eb7217ccff5ec52bdd5f2d10db8f053aeb23342eca",
+    "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/source.json": "d027d264e6b6e7fc421e38189f4374fcd14a67e0bd6e0e705de8c2185c3787e1",
     "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
     "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
     "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101",
@@ -180,7 +178,6 @@
     "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
     "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68",
     "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
-    "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
     "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191",
     "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
     "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
@@ -212,28 +209,6 @@
     "https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
     "https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel": "4a57ff2ffc2a3570e3c5646575c5a4b07287e91bcdac5d1f72383d51502b48cb",
     "https://bcr.bazel.build/modules/rules_go/0.60.0/source.json": "1e21368c5e0c3013a110bd79a8fcff8ca46b5bcb2b561713a7273cbfcff7c464",
-    "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
-    "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
-    "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39",
-    "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963",
-    "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6",
-    "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31",
-    "https://bcr.bazel.build/modules/rules_java/7.1.0/MODULE.bazel": "30d9135a2b6561c761bd67bd4990da591e6bdc128790ce3e7afd6a3558b2fb64",
-    "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a",
-    "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6",
-    "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab",
-    "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2",
-    "https://bcr.bazel.build/modules/rules_java/7.4.0/MODULE.bazel": "a592852f8a3dd539e82ee6542013bf2cadfc4c6946be8941e189d224500a8934",
-    "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe",
-    "https://bcr.bazel.build/modules/rules_java/8.13.0/MODULE.bazel": "0444ebf737d144cf2bb2ccb368e7f1cce735264285f2a3711785827c1686625e",
-    "https://bcr.bazel.build/modules/rules_java/8.16.1/MODULE.bazel": "0f20b1cecaa8e52f60a8f071e59a20b4e3b9a67f6c56c802ea256f6face692d3",
-    "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017",
-    "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939",
-    "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71",
-    "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2",
-    "https://bcr.bazel.build/modules/rules_java/8.6.3/MODULE.bazel": "e90505b7a931d194245ffcfb6ff4ca8ef9d46b4e830d12e64817752e0198e2ed",
-    "https://bcr.bazel.build/modules/rules_java/8.9.0/MODULE.bazel": "e17c876cb53dcd817b7b7f0d2985b710610169729e8c371b2221cacdcd3dce4a",
-    "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d",
     "https://bcr.bazel.build/modules/rules_java/9.3.0/MODULE.bazel": "f657c72d65ac449caae9abf2e68e66c0d36f9416848c4c4903d0b3234229e7f2",
     "https://bcr.bazel.build/modules/rules_java/9.3.0/source.json": "59ae7e662c3c7042b88bbb42ad12483523e234c65ebe4c51611baa43e85cb248",
     "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
@@ -262,8 +237,8 @@
     "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb",
     "https://bcr.bazel.build/modules/rules_nodejs/6.2.0/MODULE.bazel": "ec27907f55eb34705adb4e8257952162a2d4c3ed0f0b3b4c3c1aad1fac7be35e",
     "https://bcr.bazel.build/modules/rules_nodejs/6.7.3/MODULE.bazel": "c22a48b2a0dbf05a9dc5f83837bbc24c226c1f6e618de3c3a610044c9f336056",
-    "https://bcr.bazel.build/modules/rules_nodejs/6.7.4/MODULE.bazel": "e6a241a55c82e999145553d2e00a08fc6ebadf62b63d108fb5e984696ffd0bd2",
-    "https://bcr.bazel.build/modules/rules_nodejs/6.7.4/source.json": "34e7a8a3b4c8d630ac0e0492b3fed9dba41fe008a0edf220b7d88fa38ac53698",
+    "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/MODULE.bazel": "97e6794043821d23c013baa4a50fd1c599f2e6ae92b06e2c5f1cd7074fd83e7c",
+    "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/source.json": "d60ee5a76258b1c8f99545ed24172b44d43ba64ca1a2dfc04371ef203df19fdf",
     "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
     "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
     "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a",
@@ -336,49 +311,31 @@
   },
   "selectedYankedVersions": {},
   "moduleExtensions": {
-    "@@aspect_rules_ts+//ts:extensions.bzl%ext": {
-      "general": {
-        "bzlTransitiveDigest": "znGIWhsWB2BLftYOmjVGiqrZuccFABBogiS+i4Kg3tU=",
-        "usagesDigest": "wmTY4QJrwyD3VUSOHaA0OPkvX0ywR/XPM2EecaJ8yGk=",
-        "recordedInputs": [
-          "REPO_MAPPING:aspect_rules_ts+,aspect_rules_ts aspect_rules_ts+",
-          "REPO_MAPPING:aspect_rules_ts+,bazel_tools bazel_tools",
-          "FILE:@@//package.json f230bdb8c76c59a925ec0727a1decc727388d47d4b20d95b4e5bcf328edc0c79"
-        ],
-        "generatedRepoSpecs": {
-          "npm_typescript": {
-            "repoRuleId": "@@aspect_rules_ts+//ts/private:npm_repositories.bzl%http_archive_version",
-            "attributes": {
-              "version": "",
-              "version_from": "@@//:package.json",
-              "integrity": "",
-              "urls": [
-                "https://registry.npmjs.org/typescript/-/typescript-{}.tgz"
-              ]
-            }
-          }
-        }
-      }
-    },
     "@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
       "general": {
-        "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=",
-        "usagesDigest": "VyFtm1DFYJGrU6mU+xj6UF6QCpzpFzBONF0/8BC6ZC8=",
+        "bzlTransitiveDigest": "4w9RM0xjdKo1crk5zL20a/TuhqO0P1z1LsuXDneBXD4=",
+        "usagesDigest": "VuWbxUOa4PEE7KktNjLNLRMFWZRv30gglkSl7mxZj9w=",
         "recordedInputs": [
           "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+",
-          "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+"
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+",
+          "ENV:ASPECT_TOOLS_TELEMETRY_TEST \\0"
         ],
         "generatedRepoSpecs": {
           "aspect_tools_telemetry_report": {
             "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository",
             "attributes": {
               "deps": {
-                "aspect_rules_js": "3.1.2",
-                "aspect_rules_ts": "3.8.8",
-                "aspect_tools_telemetry": "0.3.3"
-              }
+                "aspect_rules_js": "3.2.3",
+                "aspect_rules_ts": "3.9.2",
+                "aspect_tools_telemetry": "0.4.2"
+              },
+              "last_notice": 1
             }
           }
+        },
+        "moduleExtensionMetadata": {
+          "useAllRepos": "NO",
+          "reproducible": false
         }
       }
     },
@@ -474,425 +431,6 @@
         }
       }
     },
-    "@@rules_nodejs+//nodejs:extensions.bzl%node": {
-      "general": {
-        "bzlTransitiveDigest": "oZFClfRhTTwsYzpxVPkOpOt/r0+OzEfEV37au0jFZ0s=",
-        "usagesDigest": "rmuj3zuYtKpBbldwwQvwLpB0VwZVHNwpx5rco2PvuLY=",
-        "recordedInputs": [],
-        "generatedRepoSpecs": {
-          "nodejs_linux_amd64": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "linux_amd64"
-            }
-          },
-          "nodejs_linux_arm64": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "linux_arm64"
-            }
-          },
-          "nodejs_linux_s390x": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "linux_s390x"
-            }
-          },
-          "nodejs_linux_ppc64le": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "linux_ppc64le"
-            }
-          },
-          "nodejs_darwin_amd64": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "darwin_amd64"
-            }
-          },
-          "nodejs_darwin_arm64": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "darwin_arm64"
-            }
-          },
-          "nodejs_windows_amd64": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "windows_amd64"
-            }
-          },
-          "nodejs_windows_arm64": {
-            "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
-            "attributes": {
-              "node_download_auth": {},
-              "node_repositories": {
-                "24.14.1-darwin_arm64": [
-                  "node-v24.14.1-darwin-arm64.tar.gz",
-                  "node-v24.14.1-darwin-arm64",
-                  "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
-                ],
-                "24.14.1-darwin_amd64": [
-                  "node-v24.14.1-darwin-x64.tar.gz",
-                  "node-v24.14.1-darwin-x64",
-                  "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
-                ],
-                "24.14.1-linux_arm64": [
-                  "node-v24.14.1-linux-arm64.tar.xz",
-                  "node-v24.14.1-linux-arm64",
-                  "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"
-                ],
-                "24.14.1-linux_ppc64le": [
-                  "node-v24.14.1-linux-ppc64le.tar.xz",
-                  "node-v24.14.1-linux-ppc64le",
-                  "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"
-                ],
-                "24.14.1-linux_s390x": [
-                  "node-v24.14.1-linux-s390x.tar.xz",
-                  "node-v24.14.1-linux-s390x",
-                  "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"
-                ],
-                "24.14.1-linux_amd64": [
-                  "node-v24.14.1-linux-x64.tar.xz",
-                  "node-v24.14.1-linux-x64",
-                  "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"
-                ],
-                "24.14.1-windows_amd64": [
-                  "node-v24.14.1-win-x64.zip",
-                  "node-v24.14.1-win-x64",
-                  "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
-                ]
-              },
-              "node_urls": [
-                "https://nodejs.org/dist/v{version}/{filename}"
-              ],
-              "node_version": "24.14.1",
-              "include_headers": false,
-              "platform": "windows_arm64"
-            }
-          },
-          "nodejs": {
-            "repoRuleId": "@@rules_nodejs+//nodejs/private:nodejs_repo_host_os_alias.bzl%nodejs_repo_host_os_alias",
-            "attributes": {
-              "user_node_repository_name": "nodejs"
-            }
-          },
-          "nodejs_host": {
-            "repoRuleId": "@@rules_nodejs+//nodejs/private:nodejs_repo_host_os_alias.bzl%nodejs_repo_host_os_alias",
-            "attributes": {
-              "user_node_repository_name": "nodejs"
-            }
-          },
-          "nodejs_toolchains": {
-            "repoRuleId": "@@rules_nodejs+//nodejs/private:nodejs_toolchains_repo.bzl%nodejs_toolchains_repo",
-            "attributes": {
-              "user_node_repository_name": "nodejs"
-            }
-          }
-        }
-      }
-    },
     "@@rules_python+//python/uv:uv.bzl%uv": {
       "general": {
         "bzlTransitiveDigest": "I8FPZMevE2oI/peSpMBRVIN++WOtfjtJVjbPsBZQ87A=",
@@ -1287,7 +825,7 @@
     "@@yq.bzl+//yq:extensions.bzl%yq": {
       "general": {
         "bzlTransitiveDigest": "tDqk+ntWTdxNAWPDjRY1uITgHbti2jcXR5ZdinltBs0=",
-        "usagesDigest": "Pz33iK5QtkPFivaljfReN4KzpAqM2DgY4vwgyZSNoAM=",
+        "usagesDigest": "XGLRpNcVs4WD/zog6U0sXbBo2OlR5O4mc58OU1L3EVc=",
         "recordedInputs": [],
         "generatedRepoSpecs": {
           "yq_darwin_amd64": {
@@ -1364,6 +902,9 @@
     }
   },
   "facts": {
+    "@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
+      "notice_version": "1"
+    },
     "@@rules_go+//go:extensions.bzl%go_sdk": {
       "1.22.4": {
         "aix_ppc64": [
diff --git a/configs/agent_configs.textproto b/configs/agent_configs.textproto
index 08f2602..c16bf16b 100644
--- a/configs/agent_configs.textproto
+++ b/configs/agent_configs.textproto
@@ -46,7 +46,6 @@
     project: "gerrit"
     path_regex: "^/?COMMIT_MSG$"
   }
-  automatic: true
 }
 
 # TypeScript Style Review Agent
@@ -60,5 +59,6 @@
     project: "TestRepo"
     path_regex: ".*\\.ts$"
   }
+  automatic: true
 }
 
diff --git a/contrib/maintenance/gerrit/site.py b/contrib/maintenance/gerrit/site.py
index faf6c02..450acaa 100644
--- a/contrib/maintenance/gerrit/site.py
+++ b/contrib/maintenance/gerrit/site.py
@@ -37,11 +37,11 @@
             ) as cfg:
                 config_base_path = cfg.get("gerrit", None, "basePath", "git")
                 if os.path.isabs(config_base_path):
-                    self.basePath = config_base_path
+                    self.base_path = config_base_path
                 else:
-                    self.basePath = os.path.join(self.path, config_base_path)
+                    self.base_path = os.path.join(self.path, config_base_path)
 
-        return self.basePath
+        return self.base_path
 
     def get_projects(self, excludes=None):
         for current, dirs, _ in os.walk(self.get_base_path(), topdown=True):
diff --git a/external_deps.lock.json b/external_deps.lock.json
index b4b538d..141bbfc 100644
--- a/external_deps.lock.json
+++ b/external_deps.lock.json
@@ -8,10 +8,10 @@
     "com.github.ben-manes.caffeine:caffeine": 1735183231,
     "com.github.ben-manes.caffeine:guava": -2131703186,
     "com.github.rholder:guava-retrying": 1309513165,
-    "com.google.auto.factory:auto-factory": -1233360965,
-    "com.google.auto.service:auto-service-annotations": 953830824,
-    "com.google.auto.value:auto-value": 1146221104,
-    "com.google.auto.value:auto-value-annotations": -1101276935,
+    "com.google.auto.factory:auto-factory": -1222857861,
+    "com.google.auto.service:auto-service-annotations": 964333928,
+    "com.google.auto.value:auto-value": -661233359,
+    "com.google.auto.value:auto-value-annotations": 1386235898,
     "com.google.auto:auto-common": -832702775,
     "com.google.code.findbugs:jsr305": -1992157670,
     "com.google.code.gson:gson": 2063056812,
@@ -28,13 +28,13 @@
     "com.google.guava:failureaccess": -2032498474,
     "com.google.guava:guava": -1756621521,
     "com.google.guava:guava-testlib": -203887467,
-    "com.google.inject.extensions:guice-assistedinject": -1667539622,
-    "com.google.inject.extensions:guice-servlet": 569202692,
-    "com.google.inject:guice": -1660789120,
+    "com.google.inject.extensions:guice-assistedinject": -1742632647,
+    "com.google.inject.extensions:guice-servlet": -1284557873,
+    "com.google.inject:guice": 2106361664,
     "com.google.j2objc:j2objc-annotations": -727464895,
     "com.google.jimfs:jimfs": -1004381565,
     "com.google.protobuf:protobuf-java": 1247529435,
-    "com.google.template:soy": -1478719887,
+    "com.google.template:soy": -843524660,
     "com.google.truth.extensions:truth-java8-extension": -129319374,
     "com.google.truth.extensions:truth-liteproto-extension": 1463279446,
     "com.google.truth.extensions:truth-proto-extension": 1270333764,
@@ -109,7 +109,7 @@
     "commons-dbcp:commons-dbcp": -873877417,
     "commons-digester:commons-digester": 1688456634,
     "commons-io:commons-io": 1305681826,
-    "commons-logging:commons-logging": 243315756,
+    "commons-logging:commons-logging": -264381874,
     "commons-net:commons-net": 1227155931,
     "commons-pool:commons-pool": -2015226625,
     "commons-validator:commons-validator": -89255997,
@@ -119,16 +119,17 @@
     "io.github.java-diff-utils:java-diff-utils": 1296992014,
     "io.sweers.autotransient:autotransient": 1591678970,
     "jakarta.inject:jakarta.inject-api": 190564362,
+    "jakarta.servlet:jakarta.servlet-api": -3550902,
     "javax.activation:activation": -940616209,
     "javax.annotation:jsr250-api": -593155132,
     "javax.inject:javax.inject": -297932879,
     "javax.servlet:javax.servlet-api": 669233360,
     "junit:junit": -744267592,
     "log4j:log4j": 182326902,
-    "net.bytebuddy:byte-buddy": -67913315,
-    "net.bytebuddy:byte-buddy-agent": -728183915,
-    "net.java.dev.jna:jna": 939544101,
-    "net.java.dev.jna:jna-platform": 1246142177,
+    "net.bytebuddy:byte-buddy": -1875367778,
+    "net.bytebuddy:byte-buddy-agent": 1759328918,
+    "net.java.dev.jna:jna": -867910362,
+    "net.java.dev.jna:jna-platform": -561312286,
     "net.minidev:json-smart": -1043043954,
     "net.sf.jopt-simple:jopt-simple": 906822697,
     "net.sourceforge.nekohtml:nekohtml": 1723624706,
@@ -152,12 +153,12 @@
     "org.apache.lucene:lucene-queries": 1341801569,
     "org.apache.lucene:lucene-queryparser": 2090205297,
     "org.apache.lucene:lucene-sandbox": 1803509280,
-    "org.apache.mina:mina-core": -955969490,
+    "org.apache.mina:mina-core": -1403307213,
     "org.apache.sshd:sshd-common": 1844247612,
     "org.apache.sshd:sshd-core": 602454664,
-    "org.apache.sshd:sshd-mina": -1915268089,
-    "org.apache.sshd:sshd-osgi": 1681538032,
-    "org.apache.sshd:sshd-sftp": 2089761863,
+    "org.apache.sshd:sshd-mina": 582747848,
+    "org.apache.sshd:sshd-osgi": -115413327,
+    "org.apache.sshd:sshd-sftp": 292810504,
     "org.asciidoctor:asciidoctorj": -457860213,
     "org.assertj:assertj-core": -1145412507,
     "org.bouncycastle:bcpg-jdk18on": -1572213535,
@@ -168,17 +169,18 @@
     "org.commonmark:commonmark-ext-autolink": -1853742120,
     "org.commonmark:commonmark-ext-gfm-strikethrough": 350394231,
     "org.commonmark:commonmark-ext-gfm-tables": 1881582931,
-    "org.eclipse.jetty.ee8:jetty-ee8-nested": -1627496831,
-    "org.eclipse.jetty.ee8:jetty-ee8-security": 1632270986,
-    "org.eclipse.jetty.ee8:jetty-ee8-servlet": -1338697711,
-    "org.eclipse.jetty:jetty-http": -235909997,
-    "org.eclipse.jetty:jetty-io": -44878927,
-    "org.eclipse.jetty:jetty-jmx": 1781043564,
-    "org.eclipse.jetty:jetty-security": -1819173301,
-    "org.eclipse.jetty:jetty-server": -130172210,
-    "org.eclipse.jetty:jetty-session": -1378386067,
-    "org.eclipse.jetty:jetty-util": -444622835,
-    "org.eclipse.jetty:jetty-util-ajax": -622085534,
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": -562048764,
+    "org.eclipse.jetty.ee8:jetty-ee8-nested": 860016002,
+    "org.eclipse.jetty.ee8:jetty-ee8-security": -175183477,
+    "org.eclipse.jetty.ee8:jetty-ee8-servlet": 1148815122,
+    "org.eclipse.jetty:jetty-http": -2043364460,
+    "org.eclipse.jetty:jetty-io": -1852333390,
+    "org.eclipse.jetty:jetty-jmx": -26410899,
+    "org.eclipse.jetty:jetty-security": 668339532,
+    "org.eclipse.jetty:jetty-server": -1937626673,
+    "org.eclipse.jetty:jetty-session": 1109126766,
+    "org.eclipse.jetty:jetty-util": 2042889998,
+    "org.eclipse.jetty:jetty-util-ajax": 1865427299,
     "org.hamcrest:hamcrest": 1547523135,
     "org.jruby:jruby-complete": -2103568068,
     "org.json:json": -811907600,
@@ -186,7 +188,7 @@
     "org.mockito:mockito-core": 1330163800,
     "org.nibor.autolink:autolink": -342487050,
     "org.objenesis:objenesis": 748376655,
-    "org.openid4java:openid4java": -842286787,
+    "org.openid4java:openid4java": -1889179110,
     "org.openjdk.jmh:jmh-core": 983716932,
     "org.openjdk.jmh:jmh-generator-annprocess": -1162360421,
     "org.ow2.asm:asm": 1206815935,
@@ -223,14 +225,14 @@
     "com.github.ben-manes.caffeine:guava:jar:sources": 1590068668,
     "com.github.rholder:guava-retrying": -789983219,
     "com.github.rholder:guava-retrying:jar:sources": 2030887181,
-    "com.google.auto.factory:auto-factory": 1657393907,
-    "com.google.auto.factory:auto-factory:jar:sources": 1355400808,
-    "com.google.auto.service:auto-service-annotations": 1999106941,
-    "com.google.auto.service:auto-service-annotations:jar:sources": 1716120839,
-    "com.google.auto.value:auto-value": 1110951021,
-    "com.google.auto.value:auto-value-annotations": 641018093,
-    "com.google.auto.value:auto-value-annotations:jar:sources": -315473772,
-    "com.google.auto.value:auto-value:jar:sources": 1982175049,
+    "com.google.auto.factory:auto-factory": 2025609377,
+    "com.google.auto.factory:auto-factory:jar:sources": -861333607,
+    "com.google.auto.service:auto-service-annotations": 195984873,
+    "com.google.auto.service:auto-service-annotations:jar:sources": -902556490,
+    "com.google.auto.value:auto-value": -1504415167,
+    "com.google.auto.value:auto-value-annotations": 641752776,
+    "com.google.auto.value:auto-value-annotations:jar:sources": -2083311588,
+    "com.google.auto.value:auto-value:jar:sources": -30292358,
     "com.google.auto:auto-common": -195836273,
     "com.google.auto:auto-common:jar:sources": -2100626169,
     "com.google.code.findbugs:jsr305": -998441376,
@@ -260,11 +262,11 @@
     "com.google.guava:guava-testlib:jar:sources": 1351030420,
     "com.google.guava:guava:jar:sources": 1246910673,
     "com.google.guava:listenablefuture": 1079558157,
-    "com.google.inject.extensions:guice-assistedinject": 773600044,
+    "com.google.inject.extensions:guice-assistedinject": -205980308,
     "com.google.inject.extensions:guice-assistedinject:jar:sources": 550961491,
-    "com.google.inject.extensions:guice-servlet": 350832471,
+    "com.google.inject.extensions:guice-servlet": -325370690,
     "com.google.inject.extensions:guice-servlet:jar:sources": -828654042,
-    "com.google.inject:guice": 1105471728,
+    "com.google.inject:guice:jar:classes": 1808670374,
     "com.google.inject:guice:jar:sources": -2060215316,
     "com.google.j2objc:j2objc-annotations": 1702790440,
     "com.google.j2objc:j2objc-annotations:jar:sources": -1254484583,
@@ -274,15 +276,15 @@
     "com.google.jsinterop:jsinterop-annotations:jar:sources": 694679492,
     "com.google.protobuf:protobuf-java": 1331070778,
     "com.google.protobuf:protobuf-java:jar:sources": 133270104,
-    "com.google.template:soy": -677841031,
+    "com.google.template:soy": 1170788493,
     "com.google.template:soy:jar:sources": 895044971,
-    "com.google.truth.extensions:truth-java8-extension": 18757275,
+    "com.google.truth.extensions:truth-java8-extension": 766384514,
     "com.google.truth.extensions:truth-java8-extension:jar:sources": 1257445795,
-    "com.google.truth.extensions:truth-liteproto-extension": -704876174,
+    "com.google.truth.extensions:truth-liteproto-extension": -1774954418,
     "com.google.truth.extensions:truth-liteproto-extension:jar:sources": -754757455,
-    "com.google.truth.extensions:truth-proto-extension": 220019054,
+    "com.google.truth.extensions:truth-proto-extension": -180673672,
     "com.google.truth.extensions:truth-proto-extension:jar:sources": -171856482,
-    "com.google.truth:truth": 341960611,
+    "com.google.truth:truth": 2133252626,
     "com.google.truth:truth:jar:sources": 494258718,
     "com.googlecode.javaewah:JavaEWAH": 1782327838,
     "com.googlecode.javaewah:JavaEWAH:jar:sources": -77762967,
@@ -304,9 +306,9 @@
     "com.jcraft:jsch:jar:sources": -586408740,
     "com.jcraft:jzlib": -1423321085,
     "com.jcraft:jzlib:jar:sources": 1440827148,
-    "com.ryanharter.auto.value:auto-value-gson-extension": 550631721,
+    "com.ryanharter.auto.value:auto-value-gson-extension": -1071085858,
     "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources": -670606081,
-    "com.ryanharter.auto.value:auto-value-gson-factory": 151677491,
+    "com.ryanharter.auto.value:auto-value-gson-factory": -586162268,
     "com.ryanharter.auto.value:auto-value-gson-factory:jar:sources": -543638406,
     "com.ryanharter.auto.value:auto-value-gson-runtime": 1330169783,
     "com.ryanharter.auto.value:auto-value-gson-runtime:jar:sources": 706425522,
@@ -338,6 +340,8 @@
     "io.sweers.autotransient:autotransient:jar:sources": -703216994,
     "jakarta.inject:jakarta.inject-api": 188991469,
     "jakarta.inject:jakarta.inject-api:jar:sources": -1584622703,
+    "jakarta.servlet:jakarta.servlet-api": -1421863120,
+    "jakarta.servlet:jakarta.servlet-api:jar:sources": -2084357823,
     "javax.activation:activation": 1998212823,
     "javax.activation:activation:jar:sources": 2062811499,
     "javax.inject:javax.inject": 698155243,
@@ -346,14 +350,14 @@
     "javax.servlet:javax.servlet-api:jar:sources": -2015355058,
     "junit:junit": -1256429642,
     "junit:junit:jar:sources": 940567721,
-    "net.bytebuddy:byte-buddy": -645553046,
-    "net.bytebuddy:byte-buddy-agent": 567350281,
-    "net.bytebuddy:byte-buddy-agent:jar:sources": 467108684,
-    "net.bytebuddy:byte-buddy:jar:sources": -315779909,
-    "net.java.dev.jna:jna": 1254948548,
-    "net.java.dev.jna:jna-platform": 653780437,
-    "net.java.dev.jna:jna-platform:jar:sources": 194874308,
-    "net.java.dev.jna:jna:jar:sources": -1685556831,
+    "net.bytebuddy:byte-buddy": -2144538556,
+    "net.bytebuddy:byte-buddy-agent": -1307562154,
+    "net.bytebuddy:byte-buddy-agent:jar:sources": 2038081020,
+    "net.bytebuddy:byte-buddy:jar:sources": 1602176187,
+    "net.java.dev.jna:jna": 1622514527,
+    "net.java.dev.jna:jna-platform": 1756885266,
+    "net.java.dev.jna:jna-platform:jar:sources": -507721531,
+    "net.java.dev.jna:jna:jar:sources": -902150228,
     "net.minidev:json-smart": 1099098947,
     "net.minidev:json-smart:jar:sources": 1458315043,
     "net.sf.jopt-simple:jopt-simple": -1677351973,
@@ -396,17 +400,17 @@
     "org.apache.lucene:lucene-misc:jar:sources": 1204085693,
     "org.apache.lucene:lucene-queryparser": 2111539382,
     "org.apache.lucene:lucene-queryparser:jar:sources": 1992621418,
-    "org.apache.mina:mina-core": 314726127,
-    "org.apache.mina:mina-core:jar:sources": 758815578,
-    "org.apache.sshd:sshd-mina": -734078396,
-    "org.apache.sshd:sshd-mina:jar:sources": -506380854,
-    "org.apache.sshd:sshd-osgi": 1092773821,
-    "org.apache.sshd:sshd-osgi:jar:sources": -2061003935,
-    "org.apache.sshd:sshd-sftp": -70173235,
-    "org.apache.sshd:sshd-sftp:jar:sources": 1034555480,
+    "org.apache.mina:mina-core": -584591076,
+    "org.apache.mina:mina-core:jar:sources": 71382131,
+    "org.apache.sshd:sshd-mina": -36716477,
+    "org.apache.sshd:sshd-mina:jar:sources": 97528844,
+    "org.apache.sshd:sshd-osgi": 1810958617,
+    "org.apache.sshd:sshd-osgi:jar:sources": 710209222,
+    "org.apache.sshd:sshd-sftp": -320832840,
+    "org.apache.sshd:sshd-sftp:jar:sources": -1268961386,
     "org.asciidoctor:asciidoctorj": 1685789893,
     "org.asciidoctor:asciidoctorj:jar:sources": 2091708864,
-    "org.assertj:assertj-core": -923964809,
+    "org.assertj:assertj-core": -59722598,
     "org.assertj:assertj-core:jar:sources": -697161745,
     "org.bouncycastle:bcpg-jdk18on": -1258294405,
     "org.bouncycastle:bcpg-jdk18on:jar:sources": -1733408473,
@@ -428,30 +432,32 @@
     "org.commonmark:commonmark-ext-gfm-tables": -1205584749,
     "org.commonmark:commonmark-ext-gfm-tables:jar:sources": 1341057091,
     "org.commonmark:commonmark:jar:sources": -1511261547,
-    "org.eclipse.jetty.ee8:jetty-ee8-nested": -1480285467,
-    "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": -2141029892,
-    "org.eclipse.jetty.ee8:jetty-ee8-security": -1089777429,
-    "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": -374234244,
-    "org.eclipse.jetty.ee8:jetty-ee8-servlet": 709534403,
-    "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": 1796299448,
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": -2443254,
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources": -1546113901,
+    "org.eclipse.jetty.ee8:jetty-ee8-nested": -1915352225,
+    "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": 673934800,
+    "org.eclipse.jetty.ee8:jetty-ee8-security": -1918552702,
+    "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": 114520187,
+    "org.eclipse.jetty.ee8:jetty-ee8-servlet": 57123647,
+    "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": 500172952,
     "org.eclipse.jetty.toolchain:jetty-servlet-api": 1364182673,
     "org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources": 736604807,
-    "org.eclipse.jetty:jetty-http": -1578822522,
-    "org.eclipse.jetty:jetty-http:jar:sources": -1317713476,
-    "org.eclipse.jetty:jetty-io": -225847225,
-    "org.eclipse.jetty:jetty-io:jar:sources": 116677681,
-    "org.eclipse.jetty:jetty-jmx": 1202016563,
-    "org.eclipse.jetty:jetty-jmx:jar:sources": -1552312519,
-    "org.eclipse.jetty:jetty-security": 501899561,
-    "org.eclipse.jetty:jetty-security:jar:sources": 1985223928,
-    "org.eclipse.jetty:jetty-server": -963731471,
-    "org.eclipse.jetty:jetty-server:jar:sources": -466994191,
-    "org.eclipse.jetty:jetty-session": 1869188771,
-    "org.eclipse.jetty:jetty-session:jar:sources": -1630590830,
-    "org.eclipse.jetty:jetty-util": -1027747345,
-    "org.eclipse.jetty:jetty-util-ajax": 573093314,
-    "org.eclipse.jetty:jetty-util-ajax:jar:sources": 455294676,
-    "org.eclipse.jetty:jetty-util:jar:sources": -280937557,
+    "org.eclipse.jetty:jetty-http": -2005285297,
+    "org.eclipse.jetty:jetty-http:jar:sources": 1510111824,
+    "org.eclipse.jetty:jetty-io": 1963881320,
+    "org.eclipse.jetty:jetty-io:jar:sources": -219635344,
+    "org.eclipse.jetty:jetty-jmx": 974624101,
+    "org.eclipse.jetty:jetty-jmx:jar:sources": -733987429,
+    "org.eclipse.jetty:jetty-security": -628512589,
+    "org.eclipse.jetty:jetty-security:jar:sources": 730204298,
+    "org.eclipse.jetty:jetty-server": -1113673878,
+    "org.eclipse.jetty:jetty-server:jar:sources": -1325429404,
+    "org.eclipse.jetty:jetty-session": -240475595,
+    "org.eclipse.jetty:jetty-session:jar:sources": -407525695,
+    "org.eclipse.jetty:jetty-util": 1729944958,
+    "org.eclipse.jetty:jetty-util-ajax": 1312600683,
+    "org.eclipse.jetty:jetty-util-ajax:jar:sources": -2027361402,
+    "org.eclipse.jetty:jetty-util:jar:sources": -1809018264,
     "org.hamcrest:hamcrest": 1282317766,
     "org.hamcrest:hamcrest-core": 649657847,
     "org.hamcrest:hamcrest-core:jar:sources": -1646511374,
@@ -462,13 +468,13 @@
     "org.jsoup:jsoup:jar:sources": -2058254438,
     "org.jspecify:jspecify": 117231129,
     "org.jspecify:jspecify:jar:sources": -2134060298,
-    "org.mockito:mockito-core": -287830973,
+    "org.mockito:mockito-core": 493796464,
     "org.mockito:mockito-core:jar:sources": 1900207417,
     "org.nibor.autolink:autolink": -443901116,
     "org.nibor.autolink:autolink:jar:sources": -1863403724,
     "org.objenesis:objenesis": 1536526812,
     "org.objenesis:objenesis:jar:sources": 321662415,
-    "org.openid4java:openid4java": 1109904517,
+    "org.openid4java:openid4java": -469605553,
     "org.openid4java:openid4java:jar:sources": -1312563952,
     "org.openjdk.jmh:jmh-core": 262505124,
     "org.openjdk.jmh:jmh-core:jar:sources": 814651627,
@@ -561,31 +567,31 @@
     },
     "com.google.auto.factory:auto-factory": {
       "shasums": {
-        "jar": "d59fb7ada5962a480abf0b81d4d2a14a2952f17c026732359af8b585e531c16c",
-        "sources": "c6098f8976b8833cf40edc36d1e3f7cd5cbb474c018185650b5ca4e24e713e6a"
+        "jar": "9b4505cd7a60574d59386672c1d51d6154b803c892677eb909cf4155ebee771f",
+        "sources": "4f46cc87784c2339d103c3b3d4e19bc03dd3ecca018ea931c61c4b63a4829616"
       },
-      "version": "1.0.1"
+      "version": "1.1.0"
     },
     "com.google.auto.service:auto-service-annotations": {
       "shasums": {
-        "jar": "c7bec54b7b5588b5967e870341091c5691181d954cf2039f1bf0a6eeb837473b",
-        "sources": "b013ca159b0fea3a0041d3d5fbb3b7e49a819da80a172a01fb17dd28fd98e72b"
+        "jar": "cb5667036f75e9682b493b8b47adbe391bcff72b9d3e16c52ae725f514665af2",
+        "sources": "10ca2b6c041ec8a7dc07d391ab792a55a4d6518d8067c8e63b7deda92e1de9e9"
       },
-      "version": "1.0.1"
+      "version": "1.1.0"
     },
     "com.google.auto.value:auto-value": {
       "shasums": {
-        "jar": "aaf8d637bfed3c420436b9facf1b7a88d12c8785374e4202382783005319c2c3",
-        "sources": "4bff06fe077d68f964bd5e05f020ed78fd7870730441e403a2eb306360c4890a"
+        "jar": "0c556370ca3d072ba7dec6b37751ccad0e4804b6f3390cc9acb800d821dd9c0a",
+        "sources": "a9eb12d149bfbcfc7fb670689d67dea3440b0ecaea15829e2953a03a1503b29c"
       },
-      "version": "1.11.0"
+      "version": "1.11.1"
     },
     "com.google.auto.value:auto-value-annotations": {
       "shasums": {
-        "jar": "5a055ce4255333b3346e1a8703da5bf8ff049532286fdcd31712d624abe111dd",
-        "sources": "d7941e5f19bb38afcfa85350d57e5245856c23c98c2bbe32f6d31b5577f2bc33"
+        "jar": "6c61a11420a5cddf3313888e9d335d5eedfbeb9a8da26591470fbfe61f5bf859",
+        "sources": "2bc2b5c2f8b1dbe3c05673750f16ec51db2ef9bf54be942c278e16035e5dca21"
       },
-      "version": "1.11.0"
+      "version": "1.11.1"
     },
     "com.google.auto:auto-common": {
       "shasums": {
@@ -707,7 +713,7 @@
     },
     "com.google.inject:guice": {
       "shasums": {
-        "jar": "b4d4f7ec5e8fc17b4f98dee9d3f6cf6ae3ae13e2e5ed4b2f7bbf09bc4bb675d5",
+        "classes": "ff084ab91a2f00745d2535883e4c2e15f40970f93887c2df50504897119cfc3d",
         "sources": "656b82a85535ada22d251fbc4ab3e786e66997510d03325d168bc193c2148c09"
       },
       "version": "6.0.0"
@@ -969,6 +975,13 @@
       },
       "version": "2.0.1"
     },
+    "jakarta.servlet:jakarta.servlet-api": {
+      "shasums": {
+        "jar": "8a31f465f3593bf2351531a5c952014eb839da96a605b5825b93dd54714c48c4",
+        "sources": "6eb958543e0548bb93d2519e40224d13c8003b10cc615b5652bfd9899350bfb4"
+      },
+      "version": "6.1.0"
+    },
     "javax.activation:activation": {
       "shasums": {
         "jar": "ae475120e9fcd99b4b00b38329bd61cdc5eb754eee03fe66c01f50e137724f99",
@@ -999,31 +1012,31 @@
     },
     "net.bytebuddy:byte-buddy": {
       "shasums": {
-        "jar": "8b31f4ea806afaa900b67bffd8498760d1f65464f4c2ea78cdbda2f3e633898b",
-        "sources": "d40bbc426b8e79a026b4e570e6f57b0a8659b122864c874cfa309121066f1fb1"
+        "jar": "e32f454c2c1f4aca982f9ec764ed892d9a6eee7e8a77f435cbdd180f6ffdb821",
+        "sources": "7f38a50fa7406b61d867a2c81a89610bb816de2fa28696b4a54fcaa8d52530a1"
       },
-      "version": "1.18.10"
+      "version": "1.18.11"
     },
     "net.bytebuddy:byte-buddy-agent": {
       "shasums": {
-        "jar": "9cfa3c71c8f07bfb5f4c3c68db7715940d792801eba82d346fc343f7bd7c2401",
-        "sources": "af62bcc1d124110ee1917f61d4cf3c19eb832f9213a555a1d375b743327345a8"
+        "jar": "780b3601c34150ab9fb822673c472f7229c1041e8e2a12facf6a5de9ba7a2d64",
+        "sources": "30bbf5860aa58e4740b352d2c4c15fe2428a2019e81dccd25c8e3937a0a2a0a3"
       },
-      "version": "1.18.10"
+      "version": "1.18.11"
     },
     "net.java.dev.jna:jna": {
       "shasums": {
-        "jar": "f05122f39e2fd8aa1bfbdb2b0306034e2b20dc92817eba119f8c0c2f3399bf3a",
-        "sources": "3e0371bec33252b88cede182bd653ea0580063b3ebc1d97dea2122b2639675e7"
+        "jar": "4fb141dd8ef6b0585ffceea4bc49602fbc6312fa977e2c488794ea3e6aafecae",
+        "sources": "0136f719ddd91d92d2368aa2c59a0c436810ac0b9c29024044383758b0e99786"
       },
-      "version": "5.19.0"
+      "version": "5.19.1"
     },
     "net.java.dev.jna:jna-platform": {
       "shasums": {
-        "jar": "44ceeb7a0f3f9c1e74b96ecdce047f8cc85a8c72c491f7662ba64db899559870",
+        "jar": "3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82",
         "sources": "8288e781424a34b0478928be5664b01ace168e80608178d37d02464041528940"
       },
-      "version": "5.19.0"
+      "version": "5.19.1"
     },
     "net.minidev:json-smart": {
       "shasums": {
@@ -1174,31 +1187,31 @@
     },
     "org.apache.mina:mina-core": {
       "shasums": {
-        "jar": "39b2dfc8e84380bf7adab657d3d5e1625cb6592a885ebdb854ec5c6f7a3ec88d",
-        "sources": "6c7823b8ed5a8d3511b8fb7ba6166ab825a45784741cd15c9991a75e54ad0dba"
+        "jar": "09b4b5e416834e5281dd0dfccac1a10413d6f42c89f133b1c43641e34f33e840",
+        "sources": "ea8192924cc0fd86742be74097c53edbce68232e9b27099ced307912e53ef1ea"
       },
-      "version": "2.2.4"
+      "version": "2.2.9"
     },
     "org.apache.sshd:sshd-mina": {
       "shasums": {
-        "jar": "7399961346882ef50952ae9774e8dd1eac7ce232d2f82c058ade2c20c02591f1",
-        "sources": "cc526f8a54d375e87b21419e1c3ce7462637718c6e692fce3940cff98a5151a5"
+        "jar": "da1088d16ba7953444b7d9aefcfc60c9b5e000bf26542d4c3ed0573f692bf14a",
+        "sources": "a8e548e09d8744effe88d83dcd55faa87dc14c37f8a0a595b4c6af3b20e558aa"
       },
-      "version": "2.18.0"
+      "version": "2.19.0"
     },
     "org.apache.sshd:sshd-osgi": {
       "shasums": {
-        "jar": "e771c4239b1c4818c102368d57d0cc082c224eefb76224524473bf7021d1a32f",
-        "sources": "dd5838716293c8aeaf9487c50c6b313dff304e7ddca84ce16923f73865ba4d1d"
+        "jar": "0b6b9371c6a97430390b84c23c58f4470927a747e74704a0ca7d19a09a66fe37",
+        "sources": "d11b69618dba155f075571cbd9b21741953c27dfa9a1af09b4024f2183226abe"
       },
-      "version": "2.18.0"
+      "version": "2.19.0"
     },
     "org.apache.sshd:sshd-sftp": {
       "shasums": {
-        "jar": "bf52fd81d63337cb40050c70ee460c80e78a0a4b77f15da7125eb1841f527e93",
-        "sources": "5171d9bac4ee76a013beccc1fdeab93fb43aeb84172d19b929ee158b9281c648"
+        "jar": "a3ad30d2feba9fad589c7ad9117fcd9d332823697d59eb424fe2902f4d805575",
+        "sources": "e26b7df972cead5c529bca6ab3e782c27dd43b323f9a54f98d9e23d4f5d03c30"
       },
-      "version": "2.18.0"
+      "version": "2.19.0"
     },
     "org.asciidoctor:asciidoctorj": {
       "shasums": {
@@ -1284,26 +1297,33 @@
       },
       "version": "0.24.0"
     },
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": {
+      "shasums": {
+        "jar": "f88e19a29f4e46322df60fe43d5ac4d852edefad923e8c38ef17ff14dc522e84",
+        "sources": "345f50f1a2937968934f354134cf4dd7e2b94bbfec36a177bb015ba675bd345c"
+      },
+      "version": "12.1.11"
+    },
     "org.eclipse.jetty.ee8:jetty-ee8-nested": {
       "shasums": {
-        "jar": "240df6fb5bb28545183cbac87f191c0d21074b17a2c9d4562581a68a8d79c347",
-        "sources": "45b7a070430ced248fab480132c2a02c98b64181b619f11fdc27291fc60cdefc"
+        "jar": "b6b4db33b8894688a08f23aba9eee1bc951c66be12a43095f2811121a54018d8",
+        "sources": "2ba3403fb5daf6eaa10e4c3e59fa859e35e4451593d0afd681c2d57b8f6056e9"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty.ee8:jetty-ee8-security": {
       "shasums": {
-        "jar": "85351106e71c1036256488bbd2c84879d6316c67cdce3b44dda7835daf82e3c9",
-        "sources": "d5e6227dfbde0e6beb3cf2976cc64049a85b828143bb328f3de3ef06e5a4803f"
+        "jar": "9f165768c7f7b7a02c7631b38d898f7c16a729b78307c5c3d5940f5de7ee955d",
+        "sources": "7269d79523195225aab585f0bd799914b4d6a9d4746fd71758bb509e6c9b356e"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty.ee8:jetty-ee8-servlet": {
       "shasums": {
-        "jar": "f593838935e9c15b9cd6bbe8707af6f5e8204f2732c88c84b654962bac660dbe",
-        "sources": "53b2477db964532be722a1ff8f53ab4d7f88b87012f4e2b4e3360c5ecd08aff0"
+        "jar": "82bd9eed66b147f243231a164c1157d3dd72cf2d05ae9029e8b12aff9df25fec",
+        "sources": "520e666d8dc4646ccd4b274946278322a393742f54f29d9911be8f46d087c54e"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty.toolchain:jetty-servlet-api": {
       "shasums": {
@@ -1314,59 +1334,59 @@
     },
     "org.eclipse.jetty:jetty-http": {
       "shasums": {
-        "jar": "090f276739fd9bf8c30511007caec669ea3804b1df1061c37f44467474bee71d",
-        "sources": "4d416e2686881085327d3a28e349029d4ae9fc9b44db831291633f18bbf01625"
+        "jar": "0e204f4c71c5d7e138949d50bb7fb15766840ecea6ca24513fb92ee34b363f55",
+        "sources": "1410d5686da1b63ad5d290cdd09b27bd4dfb1a563619a12ef02f63ad8c174eea"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-io": {
       "shasums": {
-        "jar": "448fc0f8f6f5f7251fc46de8e3aae7da14bd7c571a8043dac3dc381d08228fa0",
-        "sources": "bf40abffd40e759b16b642e12dfacb7bca1d0c937f5456b62506cc78169f6b56"
+        "jar": "0534454eb445263a8577200f7bd009153d36f3a81ef1543e79ab551846ee3b94",
+        "sources": "d7298bbee4ebde8b35a6566212c9b04c9b19ac9dff7b84eb9a9109ddde805fe9"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-jmx": {
       "shasums": {
-        "jar": "baaaf76b139335125bfa51109c80d271de73f40ee8b5270674f56e90a66e9d81",
-        "sources": "b45a4c63d7b7ea44ca6eb9b9c5fbf1d100069bec66942c83ee553d05335a1fcb"
+        "jar": "1207fefdedd9f690d3ac37f1408af6e41f4d9d60a00491e71b3b7137ef95da6f",
+        "sources": "29621d7f6da9909f9214434fef0644c1b5ff20c7d34bc4084daf3de6f1700e86"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-security": {
       "shasums": {
-        "jar": "2f34b7895cec4e3547a1b52e12e7e92b3f3a110bf3c17637f8743ca3f4e42f0c",
-        "sources": "9e55b7f04431d4cc723a85a18ddc910e71f5b1a284c28b059d297d07509b4ff8"
+        "jar": "6d21a7cbfd1915735cab6638011998f881161fdeff94936ee8f71e37d3528e79",
+        "sources": "bd00bca2c71ba594de92e10215c705493cbb0fc79149069c6db643911715c9b9"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-server": {
       "shasums": {
-        "jar": "4b0108e87abada7027123deca17186249413232dad0a2bd58a4e70a987b5354a",
-        "sources": "5e7693e35285d286dcd0bfdc73d74e6824027eeaaa0a9b4c70c0080475c22f71"
+        "jar": "772f84baf043bbc34edd99de728f3c0b2de642c400dde0fefa763e40de2ea583",
+        "sources": "433739222a745d1a4e14c71b78601065caac517bf307b44f5f85b1cd83da48ae"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-session": {
       "shasums": {
-        "jar": "164649123d15a3f2be5c196e82aa652dff05c75362db71b0f4ab6bb35165020a",
-        "sources": "b11e8cfb2db04ac2d1633bccfd47ba9e1b3afca0b75d4354a38140ba3e94c83e"
+        "jar": "d00db0879d2dde6bc8742c3050e995314c5d423e20ca63601cf91add2547a381",
+        "sources": "b86814b8efcff1b2a79bc3ebdd9e02851d131692f2b0e7ab7b9735bbfa86d5bf"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-util": {
       "shasums": {
-        "jar": "c52b4ff62cdacca8a399c611231129e6bd1074db7e3892e78cd106725cdd0ef1",
-        "sources": "209630667d8e042f187bed4e754c3f8af7c5247888fc83b8dc97b5bb2134d435"
+        "jar": "8a8d7e063cf312b044927f817b09ad7a4517162d747f7ff891a414e8ef476c26",
+        "sources": "a1cf0445b69a53200633bc8b264cfd88a2927ce4c041048649666ad7cddaaacf"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.eclipse.jetty:jetty-util-ajax": {
       "shasums": {
-        "jar": "73dfbf388b46c9e2afbc78823ea556f8d25cf851902f99873d03df90becc2b97",
-        "sources": "4617309092393ed2f6ed76d915f24b518ccbff8c8731124bf109e346607e86b3"
+        "jar": "2fc1756acdde0bdbc44c693f2c9f5801d7843ccf823b25bd65e6fc52ed77d4f5",
+        "sources": "501f94d01324e7455f365e471acf3c5a3ad6c7c11ceeacf9514f3bc60cdc59e7"
       },
-      "version": "12.1.10"
+      "version": "12.1.11"
     },
     "org.hamcrest:hamcrest": {
       "shasums": {
@@ -1562,8 +1582,7 @@
       "com.google.auto.value:auto-value-annotations",
       "com.google.auto:auto-common",
       "com.google.guava:guava",
-      "com.squareup:javapoet",
-      "javax.inject:javax.inject"
+      "com.squareup:javapoet"
     ],
     "com.google.auto:auto-common": [
       "com.google.guava:guava"
@@ -1608,13 +1627,9 @@
       "org.jspecify:jspecify"
     ],
     "com.google.inject.extensions:guice-assistedinject": [
-      "com.google.errorprone:error_prone_annotations",
-      "com.google.inject:guice"
+      "com.google.errorprone:error_prone_annotations"
     ],
-    "com.google.inject.extensions:guice-servlet": [
-      "com.google.inject:guice"
-    ],
-    "com.google.inject:guice": [
+    "com.google.inject:guice:jar:classes": [
       "aopalliance:aopalliance",
       "com.google.guava:guava",
       "jakarta.inject:jakarta.inject-api",
@@ -1633,7 +1648,6 @@
       "com.google.flogger:flogger-system-backend",
       "com.google.flogger:google-extensions",
       "com.google.guava:guava",
-      "com.google.inject:guice",
       "com.google.protobuf:protobuf-java",
       "com.ibm.icu:icu4j",
       "javax.inject:javax.inject",
@@ -1795,6 +1809,13 @@
     "org.commonmark:commonmark-ext-gfm-tables": [
       "org.commonmark:commonmark"
     ],
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": [
+      "jakarta.servlet:jakarta.servlet-api",
+      "org.eclipse.jetty:jetty-security",
+      "org.eclipse.jetty:jetty-server",
+      "org.eclipse.jetty:jetty-session",
+      "org.slf4j:slf4j-api"
+    ],
     "org.eclipse.jetty.ee8:jetty-ee8-nested": [
       "org.eclipse.jetty.toolchain:jetty-servlet-api",
       "org.eclipse.jetty:jetty-http",
@@ -1851,7 +1872,6 @@
       "org.objenesis:objenesis"
     ],
     "org.openid4java:openid4java": [
-      "com.google.inject:guice",
       "net.sourceforge.nekohtml:nekohtml",
       "org.apache.httpcomponents:httpclient",
       "xerces:xercesImpl"
@@ -1985,8 +2005,7 @@
       "autovalue.shaded.com.google.j2objc.annotations",
       "autovalue.shaded.com.squareup.javapoet",
       "autovalue.shaded.net.ltgt.gradle.incap",
-      "autovalue.shaded.org.checkerframework.checker.nullness.qual",
-      "autovalue.shaded.org.checkerframework.framework.qual",
+      "autovalue.shaded.org.jspecify.annotations",
       "autovalue.shaded.org.objectweb.asm",
       "com.google.auto.value.extension",
       "com.google.auto.value.extension.memoized.processor",
@@ -2098,7 +2117,7 @@
     "com.google.inject.extensions:guice-servlet": [
       "com.google.inject.servlet"
     ],
-    "com.google.inject:guice": [
+    "com.google.inject:guice:jar:classes": [
       "com.google.inject",
       "com.google.inject.binder",
       "com.google.inject.internal",
@@ -2593,6 +2612,12 @@
     "jakarta.inject:jakarta.inject-api": [
       "jakarta.inject"
     ],
+    "jakarta.servlet:jakarta.servlet-api": [
+      "jakarta.servlet",
+      "jakarta.servlet.annotation",
+      "jakarta.servlet.descriptor",
+      "jakarta.servlet.http"
+    ],
     "javax.activation:activation": [
       "com.sun.activation.registries",
       "com.sun.activation.viewers",
@@ -3750,6 +3775,15 @@
       "org.commonmark.ext.gfm.tables",
       "org.commonmark.ext.gfm.tables.internal"
     ],
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": [
+      "org.eclipse.jetty.ee11.servlet",
+      "org.eclipse.jetty.ee11.servlet.internal",
+      "org.eclipse.jetty.ee11.servlet.jmx",
+      "org.eclipse.jetty.ee11.servlet.listener",
+      "org.eclipse.jetty.ee11.servlet.security",
+      "org.eclipse.jetty.ee11.servlet.security.authentication",
+      "org.eclipse.jetty.ee11.servlet.util"
+    ],
     "org.eclipse.jetty.ee8:jetty-ee8-nested": [
       "org.eclipse.jetty.ee8.nested",
       "org.eclipse.jetty.ee8.nested.jmx"
@@ -4348,7 +4382,7 @@
       "com.google.inject.extensions:guice-assistedinject:jar:sources",
       "com.google.inject.extensions:guice-servlet",
       "com.google.inject.extensions:guice-servlet:jar:sources",
-      "com.google.inject:guice",
+      "com.google.inject:guice:jar:classes",
       "com.google.inject:guice:jar:sources",
       "com.google.j2objc:j2objc-annotations",
       "com.google.j2objc:j2objc-annotations:jar:sources",
@@ -4422,6 +4456,8 @@
       "io.sweers.autotransient:autotransient:jar:sources",
       "jakarta.inject:jakarta.inject-api",
       "jakarta.inject:jakarta.inject-api:jar:sources",
+      "jakarta.servlet:jakarta.servlet-api",
+      "jakarta.servlet:jakarta.servlet-api:jar:sources",
       "javax.activation:activation",
       "javax.activation:activation:jar:sources",
       "javax.inject:javax.inject",
@@ -4512,6 +4548,8 @@
       "org.commonmark:commonmark-ext-gfm-tables",
       "org.commonmark:commonmark-ext-gfm-tables:jar:sources",
       "org.commonmark:commonmark:jar:sources",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-nested",
       "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-security",
@@ -4644,7 +4682,7 @@
       "com.google.inject.extensions:guice-assistedinject:jar:sources",
       "com.google.inject.extensions:guice-servlet",
       "com.google.inject.extensions:guice-servlet:jar:sources",
-      "com.google.inject:guice",
+      "com.google.inject:guice:jar:classes",
       "com.google.inject:guice:jar:sources",
       "com.google.j2objc:j2objc-annotations",
       "com.google.j2objc:j2objc-annotations:jar:sources",
@@ -4718,6 +4756,8 @@
       "io.sweers.autotransient:autotransient:jar:sources",
       "jakarta.inject:jakarta.inject-api",
       "jakarta.inject:jakarta.inject-api:jar:sources",
+      "jakarta.servlet:jakarta.servlet-api",
+      "jakarta.servlet:jakarta.servlet-api:jar:sources",
       "javax.activation:activation",
       "javax.activation:activation:jar:sources",
       "javax.inject:javax.inject",
@@ -4808,6 +4848,8 @@
       "org.commonmark:commonmark-ext-gfm-tables",
       "org.commonmark:commonmark-ext-gfm-tables:jar:sources",
       "org.commonmark:commonmark:jar:sources",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-nested",
       "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-security",
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index cc0c134..851ec96 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.restapi;
 
 import static com.google.common.base.Preconditions.checkState;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.common.collect.ImmutableMultimap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
@@ -23,6 +22,10 @@
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
 public abstract class Response<T> {
+  // Intentionally keep this constant local to avoid introducing an HTTP library
+  // dependency into the public gerrit-extension-api.
+  private static final int HTTP_INTERNAL_SERVER_ERROR = 500;
+
   @SuppressWarnings({"rawtypes"})
   private static final Response NONE = new None();
 
@@ -74,7 +77,7 @@
   /** Arbitrary status code with wrapped result. */
   public static <T> Response<T> withStatusCode(int statusCode, T value) {
     checkState(
-        statusCode < SC_INTERNAL_SERVER_ERROR,
+        statusCode < HTTP_INTERNAL_SERVER_ERROR,
         "Status code must be < 500. To return an internal server error REST endpoint"
             + " implementations should throw an exception");
     return new Impl<>(statusCode, value);
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 0142031..18334aa 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -38,6 +38,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:lang3",
+        "//lib/commons:net",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/httpd/ContainerAuthFilter.java b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
index 517d5db..3abe786 100644
--- a/java/com/google/gerrit/httpd/ContainerAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ContainerAuthFilter.java
@@ -66,18 +66,21 @@
   private final AccountCache accountCache;
   private final Config config;
   private final String loginHttpHeader;
+  private final RemoteUserUtil remoteUserUtil;
 
   @Inject
   ContainerAuthFilter(
       DynamicItem<WebSession> session,
       AccountCache accountCache,
       AuthConfig authConfig,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      RemoteUserUtil remoteUserUtil) {
     this.session = session;
     this.accountCache = accountCache;
     this.config = config;
 
     loginHttpHeader = firstNonNull(emptyToNull(authConfig.getLoginHttpHeader()), AUTHORIZATION);
+    this.remoteUserUtil = remoteUserUtil;
   }
 
   @Override
@@ -98,7 +101,7 @@
   }
 
   private boolean verify(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    String username = RemoteUserUtil.getRemoteUser(req, loginHttpHeader);
+    String username = remoteUserUtil.getRemoteUser(req, loginHttpHeader);
     if (username == null) {
       if (isLfsOverSshRequest(req)) {
         // LFS-over-SSH auth request cannot be authorized by container
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 9ec10e2..3d3e54b 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -18,11 +18,71 @@
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.net.util.SubnetUtils;
 
+@Singleton
 public class RemoteUserUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /**
+   * Request attribute carrying the TCP-peer address before any X-Forwarded-For rewrite.
+   *
+   * <p>HTTP layers that want their requests evaluated against {@code auth.httpTrustedProxyNetworks}
+   * must set this attribute on each request. If the attribute is unset, {@link
+   * HttpServletRequest#getRemoteAddr()} is used as the peer address.
+   *
+   * <p>Gerrit's default Jetty container wires this automatically (see {@code
+   * JettyServer.ForwardedRequestCustomizer}). Other servlet containers (Tomcat, embedded netty,
+   * etc.) can opt in by installing a {@code Filter} or equivalent that runs <em>before</em> any
+   * X-Forwarded-For rewrite (e.g., Tomcat's {@code RemoteIpValve}) and sets this attribute to
+   * {@code request.getRemoteAddr()}.
+   */
+  public static final String PROXY_REMOTE_ADDRESS_ATTR =
+      "com.google.gerrit.httpd.proxyRemoteAddress";
+
+  private final Set<SubnetUtils.SubnetInfo> trustedProxySubnets;
+  private final Set<String> trustedProxyNetworks;
+
+  @Inject
+  RemoteUserUtil(AuthConfig authConfig) {
+    // The full list of `trustedProxyNetworks` is also kept as Set<String>
+    // for allowing the single-IP matching (networks ending with '/32') fast
+    // lookup whilst the full network matching evaluation is performed
+    // through the trustedProxySubnets loop.
+    trustedProxyNetworks = authConfig.getTrustedProxyNetworks();
+
+    try {
+      trustedProxySubnets =
+          trustedProxyNetworks.stream()
+              // Filter out single IPs because they are not matched by
+              // subnetwork matching but rather direct containment in
+              // trustedProxyNetworks
+              .filter(Predicate.not(RemoteUserUtil::isSingleIp))
+              .map(SubnetUtils::new)
+              .map(SubnetUtils::getInfo)
+              .collect(Collectors.toSet());
+    } catch (IllegalArgumentException e) {
+      throw new ProvisionException("Invalid auth trusted proxy definition: " + e.getMessage(), e);
+    }
+  }
+
+  private static boolean isSingleIp(String network) {
+    return network.endsWith("/32");
+  }
+
   /**
    * Tries to get username from a request with following strategies:
    *
@@ -37,8 +97,10 @@
    * @return the extracted username or null.
    */
   @Nullable
-  public static String getRemoteUser(HttpServletRequest req, String loginHeader) {
-    if (AUTHORIZATION.equals(loginHeader)) {
+  public String getRemoteUser(HttpServletRequest req, String loginHeader) {
+    boolean isAuthorizationHeader = AUTHORIZATION.equals(loginHeader);
+
+    if (isAuthorizationHeader) {
       String user = emptyToNull(req.getRemoteUser());
       if (user != null) {
         // The container performed the authentication, and has the user
@@ -46,16 +108,55 @@
         // configured to honor HTTP authentication.
         return user;
       }
-
-      // If the container didn't do the authentication we might
-      // have done it in the front-end web server. Try to split
-      // the identity out of the Authorization header and honor it.
-      String auth = req.getHeader(AUTHORIZATION);
-      return extractUsername(auth);
     }
-    // Nonstandard HTTP header. We have been told to trust this
-    // header blindly as-is.
-    return emptyToNull(req.getHeader(loginHeader));
+
+    if (!isRequestFromTrustedProxyNetworks(req)) {
+      return null;
+    }
+
+    String auth = req.getHeader(loginHeader);
+    return isAuthorizationHeader
+        ?
+        // If the container didn't do the authentication we might
+        // have done it in the front-end web server. Try to split
+        // the identity out of the Authorization header and honor it.
+        extractUsername(auth)
+        :
+        // Nonstandard HTTP header. We have been told to trust this
+        // header blindly as-is.
+        emptyToNull(auth);
+  }
+
+  private boolean isRequestFromTrustedProxyNetworks(HttpServletRequest req) {
+    if (trustedProxyNetworks.isEmpty()) {
+      return true;
+    }
+
+    String remoteAddress = getRemoteAddress(req);
+    if (isIpv6Address(remoteAddress)) {
+      logger.atWarning().atMostEvery(1, TimeUnit.MINUTES).log(
+          "IPv6 remote address: %s - trusted proxy enforcement supports only IPv4, HTTP header"
+              + " rejected",
+          remoteAddress);
+      return false;
+    }
+
+    if (trustedProxyNetworks.contains(remoteAddress + "/32")) {
+      return true;
+    }
+
+    if (trustedProxySubnets.stream().anyMatch(subnet -> subnet.isInRange(remoteAddress))) {
+      return true;
+    }
+
+    logger.atWarning().atMostEvery(1, TimeUnit.MINUTES).log(
+        "Untrusted remote address: %s - authentication via HTTP header rejected", remoteAddress);
+    return false;
+  }
+
+  private static String getRemoteAddress(HttpServletRequest req) {
+    return MoreObjects.firstNonNull(
+        (String) req.getAttribute(PROXY_REMOTE_ADDRESS_ATTR), req.getRemoteAddr());
   }
 
   /**
@@ -90,4 +191,8 @@
       return null;
     }
   }
+
+  private static boolean isIpv6Address(String ipAddress) {
+    return ipAddress.contains(":");
+  }
 }
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index f0a8b89..710adc4 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -67,12 +67,14 @@
   private final String externalIdHeader;
   private final boolean userNameToLowerCase;
   private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final RemoteUserUtil remoteUserUtil;
 
   @Inject
   HttpAuthFilter(
       DynamicItem<WebSession> webSession,
       AuthConfig authConfig,
-      ExternalIdKeyFactory externalIdKeyFactory)
+      ExternalIdKeyFactory externalIdKeyFactory,
+      RemoteUserUtil remoteUserUtil)
       throws IOException {
     this.sessionProvider = webSession;
     this.externalIdKeyFactory = externalIdKeyFactory;
@@ -90,6 +92,7 @@
     emailHeader = emptyToNull(authConfig.getHttpEmailHeader());
     externalIdHeader = emptyToNull(authConfig.getHttpExternalIdHeader());
     userNameToLowerCase = authConfig.isUserNameToLowerCase();
+    this.remoteUserUtil = remoteUserUtil;
   }
 
   @Override
@@ -138,7 +141,7 @@
   }
 
   String getRemoteUser(HttpServletRequest req) {
-    String remoteUser = RemoteUserUtil.getRemoteUser(req, loginHeader);
+    String remoteUser = remoteUserUtil.getRemoteUser(req, loginHeader);
     return (userNameToLowerCase && remoteUser != null)
         ? remoteUser.toLowerCase(Locale.US)
         : remoteUser;
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 2f17d72..79205ed 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -325,6 +325,7 @@
     List<URL> extapi = new ArrayList<>();
     move(jars, "gerrit-extension-api-", extapi);
     move(jars, "guice-", extapi);
+    move(jars, "jakarta.inject-api-2.0.1.jar", extapi);
     move(jars, "javax.inject-1.jar", extapi);
     move(jars, "aopalliance-1.0.jar", extapi);
     move(jars, "guice-servlet-", extapi);
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 7c0f258..0964dae 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -315,7 +315,7 @@
       new CacheDisplay(
               sw,
               StreamSupport.stream(cacheMap.spliterator(), false)
-                  .map(e -> CacheInfoFactory.create(e.getExportName(), e.get()))
+                  .map(e -> CacheInfoFactory.create(e.getExportName(), e.get(), true))
                   .collect(Collectors.toList()))
           .displayCaches();
       System.out.print(sw.toString());
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 7cde777..20f5bb3 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.pgm.http.jetty.HttpLog.HttpLogFactory;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -33,6 +34,7 @@
 import com.google.inject.servlet.GuiceFilter;
 import com.google.inject.servlet.GuiceServletContextListener;
 import java.lang.management.ManagementFactory;
+import java.net.InetSocketAddress;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.nio.file.Files;
@@ -57,6 +59,7 @@
 import org.eclipse.jetty.ee8.servlet.FilterHolder;
 import org.eclipse.jetty.ee8.servlet.ServletContextHandler;
 import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.http.HttpFields;
 import org.eclipse.jetty.http.HttpScheme;
 import org.eclipse.jetty.http.HttpURI;
 import org.eclipse.jetty.http.UriCompliance;
@@ -81,6 +84,38 @@
 
 @Singleton
 public class JettyServer {
+
+  private static final ForwardedRequestCustomizer FORWARDED_REQUEST_CUSTOMIZER =
+      new ForwardedRequestCustomizer() {
+        @Override
+        public Request customize(Request request, HttpFields.Mutable responseHeaders) {
+          /*
+           * The default behavior of ForwardedRequestCustomizer is to overwrite the remote address
+           * with the value of the X-Forwarded-For header, if present.
+           * However, it does not "remember" the original remote address and therefore would
+           * prevent any validation against it.
+           *
+           * ForwardedRequestCustomizer's original code fragment:
+           * <code>
+           * if (forwarded.hasFor())
+           * {
+           *     int forPort = forwarded._for._port > 0 ? forwarded._for._port : request.getRemotePort();
+           *     request.setRemoteAddr(InetSocketAddress.createUnresolved(forwarded._for._host, forPort));
+           * }
+           * </code>
+           *
+           * What we want to achieve here is to remember what it was the original proxy address before
+           * calling super.customize() and give the possibility to fetch it later down the chain.
+           */
+          request.setAttribute(
+              RemoteUserUtil.PROXY_REMOTE_ADDRESS_ATTR,
+              ((InetSocketAddress) request.getConnectionMetaData().getRemoteSocketAddress())
+                  .getAddress()
+                  .getHostAddress());
+          return super.customize(request, responseHeaders);
+        }
+      };
+
   static class Lifecycle implements LifecycleListener {
     private final JettyServer server;
     private final Config cfg;
@@ -381,12 +416,12 @@
 
       } else if ("proxy-http".equals(u.getScheme())) {
         defaultPort = 8080;
-        config.addCustomizer(new ForwardedRequestCustomizer());
+        config.addCustomizer(FORWARDED_REQUEST_CUSTOMIZER);
         c = newServerConnector(server, acceptors, config);
 
       } else if ("proxy-https".equals(u.getScheme())) {
         defaultPort = 8080;
-        config.addCustomizer(new ForwardedRequestCustomizer());
+        config.addCustomizer(FORWARDED_REQUEST_CUSTOMIZER);
         // For a proxy that terminates TLS, mark every request as HTTPS
         // unconditionally. ForwardedRequestCustomizer alone only sets
         // isSecure() when the proxy sends X-Forwarded-Proto=https or
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index edede84..756000b 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -35,6 +35,9 @@
         exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC +
                   PROLOG_SRC,
     ),
+    # Pin AutoFactory to the javax inject API. See the "Bump auto-factory
+    # version to 1.1.0" change for why; the follow-up removes this pin.
+    javacopts = ["-Acom.google.auto.factory.InjectApi=javax"],
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index a05baf5..cfe7857 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
 import com.google.inject.Singleton;
@@ -31,7 +32,6 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
-import javax.inject.Inject;
 
 /**
  * An implementation of {@link ServiceUserClassifier} that will consider a user to be a robot if
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
index 560a1c7..1d11bb9 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.ImplementedBy;
-import javax.inject.Inject;
-import javax.inject.Singleton;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 @Singleton
 public class ExternalIdKeyFactory {
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
index aa321e7..cb3e894 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
index 02ad518..b0c35be 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.config.VersionedDefaultPreferences;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -46,7 +47,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Stream;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index 663eddd..1820e70 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -57,7 +57,9 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryableAction.Action;
 import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -67,8 +69,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
index 676640d..e1caf2f 100644
--- a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetApproval.UUID;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.inject.Singleton;
 import java.time.Instant;
 import java.util.Locale;
-import javax.inject.Singleton;
 
 /**
  * Implementation of {@link PatchSetApprovalUuidGenerator} that returns predictable {@link UUID}.
diff --git a/java/com/google/gerrit/server/cache/CacheInfoFactory.java b/java/com/google/gerrit/server/cache/CacheInfoFactory.java
index dafa186..48122db 100644
--- a/java/com/google/gerrit/server/cache/CacheInfoFactory.java
+++ b/java/com/google/gerrit/server/cache/CacheInfoFactory.java
@@ -24,11 +24,11 @@
 
 public class CacheInfoFactory {
 
-  public static CacheInfo create(Cache<?, ?> cache) {
-    return create(null, cache);
+  public static CacheInfo create(Cache<?, ?> cache, boolean includeDiskStats) {
+    return create(null, cache, includeDiskStats);
   }
 
-  public static CacheInfo create(String name, Cache<?, ?> cache) {
+  public static CacheInfo create(String name, Cache<?, ?> cache, boolean includeDiskStats) {
     CacheInfo cacheInfo = new CacheInfo();
     cacheInfo.name = name;
 
@@ -44,10 +44,12 @@
 
     if (cache instanceof PersistentCache) {
       cacheInfo.type = CacheType.DISK;
-      PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
-      cacheInfo.entries.setDisk(diskStats.size());
-      cacheInfo.entries.setSpace(diskStats.space());
-      cacheInfo.hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+      if (includeDiskStats) {
+        PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
+        cacheInfo.entries.setDisk(diskStats.size());
+        cacheInfo.entries.setSpace(diskStats.space());
+        cacheInfo.hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+      }
     } else {
       cacheInfo.type = CacheType.MEM;
     }
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 7053df0..e6dd0a7 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -59,6 +59,12 @@
             Long.class,
             new Description("Memory eviction count").setGauge().setUnit("evicted entries"),
             F_NAME);
+    CallbackMetric1<String, Long> memReq =
+        metrics.newCallbackMetric(
+            "caches/memory_request_count",
+            Long.class,
+            new Description("Memory request count").setGauge().setUnit("requests"),
+            F_NAME);
     CallbackMetric1<String, Long> perDiskEnt =
         metrics.newCallbackMetric(
             "caches/disk_cached",
@@ -79,9 +85,18 @@
                 .setGauge()
                 .setUnit("invalidated entries"),
             F_NAME);
+    CallbackMetric1<String, Long> perDiskReq =
+        metrics.newCallbackMetric(
+            "caches/disk_request_count",
+            Long.class,
+            new Description("Disk request count for persistent cache")
+                .setGauge()
+                .setUnit("requests"),
+            F_NAME);
 
     ImmutableSet<CallbackMetric<?>> cacheMetrics =
-        ImmutableSet.of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit, perDiskInvalid);
+        ImmutableSet.of(
+            memEnt, memHit, memEvict, memReq, perDiskEnt, perDiskHit, perDiskInvalid, perDiskReq);
 
     metrics.newTrigger(
         cacheMetrics,
@@ -93,12 +108,14 @@
             memEnt.set(name, c.size());
             memHit.set(name, cstats.hitRate() * 100);
             memEvict.set(name, cstats.evictionCount());
+            memReq.set(name, cstats.requestCount());
             if (c instanceof PersistentCache
                 && config.getBoolean("cache", "enableDiskStatMetrics", false)) {
               PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
               perDiskEnt.set(name, d.size());
               perDiskHit.set(name, hitRatio(d));
               perDiskInvalid.set(name, d.invalidatedCount());
+              perDiskReq.set(name, d.requestCount());
             }
           }
           cacheMetrics.forEach(CallbackMetric::prune);
diff --git a/java/com/google/gerrit/server/cache/h2/CacheStoreStartupExecutor.java b/java/com/google/gerrit/server/cache/h2/CacheStoreStartupExecutor.java
new file mode 100644
index 0000000..903173c
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/CacheStoreStartupExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2026 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.h2;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CacheStoreStartupExecutor {}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 691c3c6..000b03d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -87,6 +87,8 @@
   private final boolean pruneOnStartup;
   private final Schedule schedule;
   private final AtomicBoolean isDiskCacheReadOnly;
+  @Nullable private final ExecutorService startupExecutor;
+  private final boolean preWarmForBloomFilter;
 
   @Inject
   H2CacheFactory(
@@ -95,6 +97,7 @@
       DynamicMap<Cache<?, ?>> cacheMap,
       @Nullable @CacheCleanupExecutor ScheduledExecutorService cleanupExecutor,
       @Nullable @CacheStoreExecutor ExecutorService storeExecutor,
+      @Nullable @CacheStoreStartupExecutor ExecutorService startupExecutor,
       @Nullable @CacheDir Path cacheDir,
       Set<CacheOptions> options,
       @Named("DiskCacheReadOnly") AtomicBoolean isDiskCacheReadOnly) {
@@ -102,6 +105,7 @@
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
     pruneOnStartup = cfg.getBoolean("cachePruning", null, "pruneOnStartup", true);
+    preWarmForBloomFilter = cfg.getBoolean("cache", null, "preWarmForBloomFilter", true);
     caches = new ArrayList<>();
     schedule =
         ScheduleConfig.createSchedule(cfg, "cachePruning")
@@ -112,13 +116,15 @@
     this.cleanup = cleanupExecutor;
     this.options = options;
     this.isDiskCacheReadOnly = isDiskCacheReadOnly;
+    this.startupExecutor = startupExecutor;
   }
 
   @Override
   public void start() {
     if (executor != null) {
+      ExecutorService se = startupExecutor != null ? startupExecutor : executor;
       for (H2CacheImpl<?, ?> cache : caches) {
-        executor.execute(cache::start);
+        se.execute(cache::start);
         if (cleanup != null) {
           if (pruneOnStartup) {
             @SuppressWarnings("unused")
@@ -135,6 +141,9 @@
                   TimeUnit.MILLISECONDS);
         }
       }
+      if (se != executor) {
+        se.shutdown();
+      }
     }
   }
 
@@ -142,6 +151,10 @@
   public void stop() {
     if (executor != null) {
       try {
+        if (startupExecutor != null) {
+          startupExecutor.shutdownNow();
+        }
+
         if (cleanup != null) {
           cleanup.shutdownNow();
         }
@@ -213,14 +226,10 @@
   }
 
   private <V, K> SqlStore<K, V> newSqlStore(PersistentCacheDef<K, V> def, long maxSize) {
+    String cacheName = def.name() + "-v" + COMPATIBILITY_VERSION;
     StringBuilder url = new StringBuilder();
     url.append("jdbc:h2:file:")
-        .append(
-            cacheDir
-                .resolve(def.name() + "-v" + COMPATIBILITY_VERSION)
-                .toAbsolutePath()
-                .toString()
-                .replace(";", "\\;"));
+        .append(cacheDir.resolve(cacheName).toAbsolutePath().toString().replace(";", "\\;"));
     if (h2CacheSize >= 0) {
       url.append(";CACHE_SIZE=");
       // H2 CACHE_SIZE is always given in KB
@@ -258,7 +267,9 @@
         refreshAfterWrite,
         options.contains(CacheOptions.BUILD_BLOOM_FILTER),
         options.contains(CacheOptions.TRACK_LAST_ACCESS),
-        isDiskCacheReadOnly);
+        isDiskCacheReadOnly,
+        preWarmForBloomFilter,
+        cacheDir.resolve(cacheName + ".mv.db"));
   }
 
   private boolean has(String name, String var) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 39afb38..d4a6052 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.cache.h2;
 
+import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
 import com.google.common.cache.AbstractLoadingCache;
 import com.google.common.cache.Cache;
@@ -37,7 +38,10 @@
 import com.google.gerrit.util.concurrent.ConcurrentBloomFilter;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.InvalidClassException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
@@ -208,7 +212,9 @@
   }
 
   void start() {
+    logger.atFine().log("Opening disk cache %s...", cacheName);
     store.open();
+    logger.atFine().log("Finished opening disk cache %s...", cacheName);
   }
 
   void stop() {
@@ -346,6 +352,8 @@
     private boolean trackLastAccess;
     private final AtomicBoolean isDiskCacheReadOnly;
     private volatile boolean ensuredSchemaCreation;
+    private final Path cacheFilePath;
+    private final boolean preWarmForBloomFilter;
 
     SqlStore(
         String jdbcUrl,
@@ -359,7 +367,9 @@
         @Nullable Duration refreshAfterWrite,
         boolean buildBloomFilter,
         boolean trackLastAccess,
-        AtomicBoolean isDiskCacheReadOnly) {
+        AtomicBoolean isDiskCacheReadOnly,
+        boolean preWarmForBloomFilter,
+        Path cacheFilePath) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
@@ -370,6 +380,8 @@
       this.buildBloomFilter = buildBloomFilter;
       this.trackLastAccess = trackLastAccess;
       this.isDiskCacheReadOnly = isDiskCacheReadOnly;
+      this.cacheFilePath = cacheFilePath;
+      this.preWarmForBloomFilter = preWarmForBloomFilter;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -422,6 +434,24 @@
       }
     }
 
+    void warmupOsPageCache() {
+      // null check because tests use in-memory h2.
+      if (cacheFilePath == null || !Files.exists(cacheFilePath)) {
+        return;
+      }
+      logger.atFine().log("Warming OS page cache for %s", cacheFilePath.getFileName());
+      Stopwatch sw = Stopwatch.createStarted();
+      byte[] buf = new byte[65536];
+      try (InputStream in = Files.newInputStream(cacheFilePath)) {
+        while (in.read(buf) != -1) {}
+      } catch (IOException e) {
+        logger.atWarning().log(
+            "Failed to warm OS page cache for %s: %s", cacheFilePath.getFileName(), e.getMessage());
+      }
+      logger.atFine().log(
+          "Finished warming OS page cache for %s after %s", cacheFilePath.getFileName(), sw);
+    }
+
     void open() {
       bloomFilter.initIfNeeded();
     }
@@ -459,6 +489,9 @@
     }
 
     private void buildBloomFilter() {
+      if (preWarmForBloomFilter) {
+        warmupOsPageCache();
+      }
       SqlHandle c = null;
       try (TraceTimer ignored = TraceContext.newTimer("Build bloom filter", Metadata.empty())) {
         c = acquire();
@@ -748,6 +781,7 @@
     }
 
     DiskStats diskStats() {
+      warmupOsPageCache();
       long size = 0;
       long space = 0;
       SqlHandle c = null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheModule.java b/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
index baf08e6..0ac8123 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
@@ -115,6 +115,24 @@
   }
 
   @Provides
+  @Singleton
+  @Nullable
+  @CacheStoreStartupExecutor
+  ExecutorService createDiskCacheStoreStartupExecutor(
+      @Nullable @CacheDir Path cacheDir, @GerritServerConfig Config cfg) {
+    if (cacheDir == null) {
+      return null;
+    }
+    int startupThreads = cfg.getInt("cache", null, "startupThreads", 1);
+    if (startupThreads > 1) {
+      return new LoggingContextAwareExecutorService(
+          Executors.newFixedThreadPool(
+              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-Startup-%d").build()));
+    }
+    return null;
+  }
+
+  @Provides
   Set<CacheOptions> getOptions() {
     return options;
   }
diff --git a/java/com/google/gerrit/server/change/CommentsValidator.java b/java/com/google/gerrit/server/change/CommentsValidator.java
index c02fb83..9b75b5e 100644
--- a/java/com/google/gerrit/server/change/CommentsValidator.java
+++ b/java/com/google/gerrit/server/change/CommentsValidator.java
@@ -33,12 +33,12 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/change/ParentDataProvider.java b/java/com/google/gerrit/server/change/ParentDataProvider.java
index f5fe120..16b9f71 100644
--- a/java/com/google/gerrit/server/change/ParentDataProvider.java
+++ b/java/com/google/gerrit/server/change/ParentDataProvider.java
@@ -28,11 +28,8 @@
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.ReachabilityChecker;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -70,19 +67,12 @@
   /** Returns true if the parent commit {@code parentCommitId} is merged in the target branch. */
   private boolean isMergedInTargetBranch(
       Project.NameKey project, Repository repo, ObjectId parentCommitId, String targetBranch) {
-    try (RevWalk rw = new RevWalk(repo);
-        ObjectReader reader = repo.newObjectReader()) {
+    try (RevWalk rw = new RevWalk(repo)) {
       Ref targetBranchRef = repo.exactRef(targetBranch);
-      if (targetBranchRef == null) {
-        return false;
+      if (targetBranchRef != null) {
+        return rw.isMergedInto(
+            rw.parseCommit(parentCommitId), rw.parseCommit(targetBranchRef.getObjectId()));
       }
-      RevCommit parent = rw.parseCommit(parentCommitId);
-      RevCommit targetBranchCommit = rw.parseCommit(targetBranchRef.getObjectId());
-      ReachabilityChecker checker = reader.createReachabilityChecker(rw);
-      Optional<RevCommit> unreachable =
-          checker.areAllReachable(
-              ImmutableList.of(parent), ImmutableList.of(targetBranchCommit).stream());
-      return unreachable.isEmpty();
     } catch (IOException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if parent commit %s (project: %s) is merged into target branch %s",
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index 7886cb5..f43a8b8 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -33,6 +34,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -41,6 +43,7 @@
 public class AuthConfig {
   private final AuthType authType;
   private final String httpHeader;
+  private final ImmutableSet<String> trustedProxyNetworks;
   private final String httpDisplaynameHeader;
   private final String httpEmailHeader;
   private final String httpExternalIdHeader;
@@ -78,6 +81,8 @@
   AuthConfig(@GerritServerConfig Config cfg) throws XsrfException {
     authType = toType(cfg);
     httpHeader = cfg.getString("auth", null, "httpheader");
+    trustedProxyNetworks =
+        ImmutableSet.copyOf(cfg.getStringList("auth", null, "httpTrustedProxyNetworks"));
     httpDisplaynameHeader = cfg.getString("auth", null, "httpdisplaynameheader");
     httpEmailHeader = cfg.getString("auth", null, "httpemailheader");
     httpExternalIdHeader = cfg.getString("auth", null, "httpexternalidheader");
@@ -384,4 +389,8 @@
   public boolean isHttpPasswordFallbackEnabled() {
     return httpPasswordFallbackEnabled;
   }
+
+  public Set<String> getTrustedProxyNetworks() {
+    return trustedProxyNetworks;
+  }
 }
diff --git a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
index db21e1f..77c5617 100644
--- a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
@@ -17,8 +17,8 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Optional;
-import javax.inject.Singleton;
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 6380db3..f339d76 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -62,9 +62,14 @@
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
@@ -90,6 +95,7 @@
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final DiffOperations diffOperations;
+  private final PatchListCache patchListCache;
   private final Emails emails;
   private final Provider<PersonIdent> myIdent;
   private final ChangeData.Factory changeDataFactory;
@@ -105,6 +111,7 @@
       Emails emails,
       DynamicItem<UrlFormatter> urlFormatter,
       DiffOperations diffOperations,
+      PatchListCache patchListCache,
       @GerritPersonIdent Provider<PersonIdent> myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -116,6 +123,7 @@
     this.urlFormatter = urlFormatter;
     this.emails = emails;
     this.diffOperations = diffOperations;
+    this.patchListCache = patchListCache;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
@@ -460,7 +468,6 @@
     p.ref = patchSet.refName();
     p.uploader = asAccountAttribute(patchSet.uploader(), accountLoader);
     p.createdOn = patchSet.createdOn().getEpochSecond();
-    PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
       RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
@@ -478,20 +485,20 @@
         p.author = asAccountAttribute(author.getAccount(), accountLoader);
       }
 
-      Map<String, FileDiffOutput> modifiedFiles =
-          diffOperations.listModifiedFilesAgainstParent(
-              changeData.project(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
-      for (FileDiffOutput fileDiff : modifiedFiles.values()) {
-        p.sizeDeletions += fileDiff.deletions();
-        p.sizeInsertions += fileDiff.insertions();
-      }
+      ChangedLines changedLines =
+          patchListCache
+              .getDiffSummary(
+                  DiffSummaryKey.fromPatchListKey(
+                      PatchListKey.againstBase(patchSet.commitId(), c.getParentCount())),
+                  changeData.project())
+              .getChangedLines();
+      p.sizeDeletions = changedLines.deletions;
+      p.sizeInsertions = changedLines.insertions;
       p.kind =
           changeKindCache.getChangeKind(
               revWalk, repoConfig, attributesNodeProvider, changeData, patchSet);
-    } catch (IOException | StorageException e) {
+    } catch (IOException | StorageException | PatchListNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
-    } catch (DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Cannot get size information for %s.", pId);
     }
     return p;
   }
diff --git a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
index 079f660..dee7f8a 100644
--- a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
+++ b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
@@ -30,12 +30,12 @@
 import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Default versions of Gerrit email notifications. */
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
index 25b2ebd..46bf8cc 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -23,13 +23,13 @@
 import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.time.Duration;
 import java.util.concurrent.ExecutionException;
-import javax.inject.Provider;
 
 /**
  * Provides support for soy templates
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
index 6f48aa0..b7cee28 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -35,7 +36,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 2d2ec18..3941975 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import javax.inject.Inject;
 
 /** Access control management for a user accessing a single change. */
 public class ChangeControl extends AbstractChangeControl {
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index 756e922..665f995 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
-import javax.inject.Inject;
-import javax.inject.Singleton;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.Constants;
 
 /**
diff --git a/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java b/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
index d592d17..20a032d 100644
--- a/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
+++ b/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
@@ -23,7 +23,7 @@
   public static Annotation calculateBindAnnotation(Class<Object> impl) {
     Annotation n = impl.getAnnotation(Export.class);
     if (n == null) {
-      n = impl.getAnnotation(javax.inject.Named.class);
+      n = impl.getAnnotation(jakarta.inject.Named.class);
     }
     if (n == null) {
       n = impl.getAnnotation(com.google.inject.name.Named.class);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 957b2e2..d0943c7 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -175,7 +175,9 @@
               .parse(expression.expressionString());
       PredicateResult predicateResult = changeData.evaluatePredicateTree(predicate);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
-    } catch (QueryParseException | SubmitRequirementEvaluationException e) {
+    } catch (QueryParseException
+        | SubmitRequirementEvaluationException
+        | IllegalArgumentException e) {
       logger.atWarning().withCause(e).log(
           "Failed to evaluate submit requirement expression: %s", expression.expressionString());
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
@@ -262,8 +264,8 @@
 
         return timeoutResult(sr, cd);
       } catch (ExecutionException | InterruptedException e) {
-        logger.atSevere().log("Error evaluating Submit requirement: %s", sr.name());
-        throw new RuntimeException(e);
+        logger.atSevere().withCause(e).log("Error evaluating Submit requirement: %s", sr.name());
+        return errorResult(sr, cd, e);
       }
     }
   }
@@ -288,6 +290,29 @@
         .build();
   }
 
+  private SubmitRequirementResult errorResult(SubmitRequirement sr, ChangeData cd, Throwable e) {
+    String msg =
+        (e instanceof ExecutionException && e.getCause() != null)
+            ? e.getCause().getMessage()
+            : e.getMessage();
+    if (msg == null) {
+      msg = e.toString();
+    }
+    SubmitRequirementExpressionResult error =
+        SubmitRequirementExpressionResult.error(sr.submittabilityExpression(), msg);
+
+    return SubmitRequirementResult.builder()
+        .legacy(Optional.of(false))
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
+        .submittabilityExpressionResult(Optional.of(error))
+        .applicabilityExpressionResult(
+            sr.applicabilityExpression().map(SubmitRequirementExpressionResult::notEvaluated))
+        .overrideExpressionResult(
+            sr.overrideExpression().map(SubmitRequirementExpressionResult::notEvaluated))
+        .build();
+  }
+
   /**
    * Evaluate and return all {@link SubmitRequirement}s.
    *
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 3224c09..a3b1e3e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -811,12 +811,10 @@
       }
       if (refStates != null) {
         ImmutableSet<RefState> refs = refStates.get(project);
-        if (refs != null) {
-          String metaRef = RefNames.changeMetaRef(getId());
-          for (RefState r : refs) {
-            if (r.ref().equals(metaRef)) {
-              return Optional.of(r.id());
-            }
+        String metaRef = RefNames.changeMetaRef(getId());
+        for (RefState r : refs) {
+          if (r.ref().equals(metaRef)) {
+            return Optional.of(r.id());
           }
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 161fb1e..6c1f35e 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -88,7 +88,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
   private final EditByPredicateProvider editByPredicateProvider;
-  private int batchSize = 100;
   private final Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider;
 
   @Inject
@@ -179,24 +178,7 @@
     if (hashes.size() > indexLimit || !indexes.getSearchIndex().isEnabled()) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
     }
-    // Safe batch size to avoid large OR queries in Spanner/Lucene
-    if (hashes.size() <= batchSize) {
-      return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
-    }
-    List<Predicate<ChangeData>> predicates = new ArrayList<>();
-    for (List<String> part : Iterables.partition(hashes, batchSize)) {
-      predicates.add(byCommitsOnBranchNotMergedPredicate(branch, part));
-    }
-    Set<Change.Id> seen = new HashSet<>();
-    List<ChangeData> result = new ArrayList<>();
-    for (List<ChangeData> partitionResult : query(predicates)) {
-      for (ChangeData cd : partitionResult) {
-        if (seen.add(cd.getId())) {
-          result.add(cd);
-        }
-      }
-    }
-    return result;
+    return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
   }
 
   private List<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
@@ -232,16 +214,12 @@
 
   private ImmutableList<ChangeData> byCommitsOnBranchNotMergedFromIndex(
       BranchNameKey branch, Collection<String> hashes) {
-    return query(byCommitsOnBranchNotMergedPredicate(branch, hashes));
-  }
-
-  private Predicate<ChangeData> byCommitsOnBranchNotMergedPredicate(
-      BranchNameKey branch, Collection<String> hashes) {
-    return and(
-        ref(branch),
-        project(branch.project()),
-        not(status(Change.Status.MERGED)),
-        or(commits(hashes)));
+    return query(
+        and(
+            ref(branch),
+            project(branch.project()),
+            not(status(Change.Status.MERGED)),
+            or(commits(hashes))));
   }
 
   private static List<Predicate<ChangeData>> commits(Collection<String> hashes) {
@@ -367,9 +345,4 @@
     }
     return result.build();
   }
-
-  @VisibleForTesting
-  public void setBatchSizeForTesting(int batchSize) {
-    this.batchSize = batchSize;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 3ce7e38..9f4e6d2 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -30,6 +30,8 @@
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.account.AccountAttributeLoader;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
@@ -224,6 +226,10 @@
           AccountAttributeLoader accountLoader = accountAttributeLoaderFactory.create();
           List<ChangeAttribute> changeAttributes = new ArrayList<>();
           for (ChangeData d : results.entities()) {
+            if (Thread.currentThread().isInterrupted()) {
+              throw new RequestCancelledException(
+                  RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, null);
+            }
             changeAttributes.add(
                 buildChangeAttribute(d, repos, revWalks, accountLoader, attributesNodeProviders));
           }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 8966ec4..05ac95e 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
@@ -69,6 +70,9 @@
   private final Provider<AccountQueryProcessor> queryProcessorProvider;
   private final boolean suggestConfig;
   private final int suggestFrom;
+  private final AccountControl.Factory accountControlFactory;
+
+  private final AccountVisibility accountVisibility;
 
   private AccountLoader accountLoader;
   private boolean suggest;
@@ -134,11 +138,15 @@
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder queryBuilder,
       Provider<AccountQueryProcessor> queryProcessorProvider,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      AccountControl.Factory accountControlFactory,
+      AccountVisibility accountVisibility) {
     this.permissionBackend = permissionBackend;
     this.accountLoaderFactory = accountLoaderFactory;
     this.queryBuilder = queryBuilder;
     this.queryProcessorProvider = queryProcessorProvider;
+    this.accountControlFactory = accountControlFactory;
+    this.accountVisibility = accountVisibility;
     this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
     this.options = EnumSet.noneOf(ListAccountsOption.class);
 
@@ -167,8 +175,14 @@
       return Response.ok(Collections.emptyList());
     }
 
+    AccountControl accountControl = accountControlFactory.get();
+    boolean canSeeDetails =
+        accountVisibility == AccountVisibility.ALL
+            || accountControl.getUser().isIdentifiedUser()
+            || accountControl.canViewAll();
+
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
-    if (options.contains(ListAccountsOption.DETAILS)) {
+    if (options.contains(ListAccountsOption.DETAILS) && canSeeDetails) {
       fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
     }
     boolean modifyAccountCapabilityChecked = false;
@@ -179,13 +193,12 @@
       fillOptions.add(FillOptions.SECONDARY_EMAILS);
     }
     if (suggest) {
-      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-      fillOptions.add(FillOptions.EMAIL);
+      if (canSeeDetails) {
+        fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+        fillOptions.add(FillOptions.EMAIL);
 
-      if (modifyAccountCapabilityChecked) {
-        fillOptions.add(FillOptions.SECONDARY_EMAILS);
-      } else {
-        if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+        if (modifyAccountCapabilityChecked
+            || permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
           fillOptions.add(FillOptions.SECONDARY_EMAILS);
         }
       }
@@ -197,6 +210,7 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
+    queryProcessor.enforceVisibility(true);
     queryProcessor.setUserProvidedLimit(limit != null ? limit : 0, /* applyDefaultLimit */ true);
 
     if (start != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
index 7b7ee13..88ec9c9 100644
--- a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
+++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -44,14 +44,14 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
 import java.util.Optional;
-import javax.inject.Inject;
-import javax.inject.Provider;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 8ac5d0c..e388e3f 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -83,6 +84,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -112,6 +114,10 @@
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
@@ -165,6 +171,8 @@
   private final CommentsValidator commentsValidator;
   private final CommitUtil commitUtil;
 
+  private final GitRepositoryManager gitManager;
+
   @Inject
   PostReview(
       RetryHelper retryHelper,
@@ -185,7 +193,8 @@
       ReviewerAdded reviewerAdded,
       ChangeJson.Factory changeJsonFactory,
       CommentsValidator commentsValidator,
-      CommitUtil commitUtil) {
+      CommitUtil commitUtil,
+      GitRepositoryManager gitManager) {
     this.retryHelper = retryHelper;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
@@ -205,6 +214,7 @@
     this.changeJsonFactory = changeJsonFactory;
     this.commentsValidator = commentsValidator;
     this.commitUtil = commitUtil;
+    this.gitManager = gitManager;
   }
 
   @Override
@@ -339,23 +349,34 @@
       }
     }
 
-    BatchUpdates.Result batchUpdateResult =
-        runBatchUpdate(projectState, revision, input, ts, notify, reviewerResults, ccOrReviewer);
-    ChangeData cd =
-        batchUpdateResult.getChangeData(revision.getProject(), revision.getChange().getId());
-    for (ReviewerModification reviewerResult : reviewerResults) {
-      reviewerResult.gatherResults(cd);
-    }
+    try (Repository repo = gitManager.openRepository(projectState.getProject().getNameKey())) {
+      BatchUpdates.Result batchUpdateResult =
+          runBatchUpdate(
+              projectState, revision, input, ts, notify, reviewerResults, ccOrReviewer, repo);
+      ChangeData cd =
+          batchUpdateResult.getChangeData(revision.getProject(), revision.getChange().getId());
+      ObjectId oldId = cd.metaRevisionOrThrow();
+      ObjectId newId = repo.exactRef(RefNames.changeMetaRef(cd.change().getId())).getObjectId();
 
-    // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
-    // email/event here.
-    batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
-    batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+      if (!oldId.equals(newId)) {
+        // Re-read change to take into account results of the update.
+        cd.reloadChange();
+      }
 
-    if (input.responseFormatOptions != null) {
-      output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
-    } else {
-      output.changeInfo = changeJsonFactory.noOptions().format(cd);
+      for (ReviewerModification reviewerResult : reviewerResults) {
+        reviewerResult.gatherResults(cd);
+      }
+
+      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+      // email/event here.
+      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+
+      if (input.responseFormatOptions != null) {
+        output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
+      } else {
+        output.changeInfo = changeJsonFactory.noOptions().format(cd);
+      }
     }
 
     return Response.ok(output);
@@ -368,7 +389,8 @@
       Instant ts,
       NotifyResolver.Result notify,
       List<ReviewerModification> reviewerResults,
-      boolean ccOrReviewer)
+      boolean ccOrReviewer,
+      Repository repo)
       throws UpdateException, RestApiException {
     return retryHelper
         .changeUpdate(
@@ -376,8 +398,11 @@
             updateFactory -> {
               try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
                 try (BatchUpdate bu =
-                    updateFactory.create(
-                        revision.getChange().getProject(), revision.getUser(), ts)) {
+                        updateFactory.create(
+                            revision.getChange().getProject(), revision.getUser(), ts);
+                    ObjectInserter oi = repo.newObjectInserter();
+                    RevWalk revWalk = new RevWalk(oi.newReader())) {
+                  bu.setRepository(repo, revWalk, oi);
                   bu.setNotify(notify);
 
                   // Apply reviewer changes first. Revision emails should be sent to the
diff --git a/java/com/google/gerrit/server/restapi/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
index 23615fa..0f06128 100644
--- a/java/com/google/gerrit/server/restapi/config/GetCache.java
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -26,6 +26,6 @@
 
   @Override
   public Response<CacheInfo> apply(CacheResource rsrc) {
-    return Response.ok(CacheInfoFactory.create(rsrc.getName(), rsrc.getCache()));
+    return Response.ok(CacheInfoFactory.create(rsrc.getName(), rsrc.getCache(), true));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index 6dc17ce..6411615 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -35,6 +35,7 @@
 import com.google.inject.Inject;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
@@ -50,22 +51,34 @@
   @Option(name = "--format", usage = "output format")
   private OutputFormat format;
 
+  @Option(
+      name = "--include-diskstats",
+      usage = "if set, disk stat collection is included for persistent caches")
+  private boolean includeDiskStats;
+
   public ListCaches setFormat(OutputFormat format) {
     this.format = format;
     return this;
   }
 
+  public ListCaches setIncludeDiskStats(boolean includeDiskStats) {
+    this.includeDiskStats = includeDiskStats;
+    return this;
+  }
+
   @Inject
   public ListCaches(DynamicMap<Cache<?, ?>> cacheMap) {
     this.cacheMap = cacheMap;
   }
 
-  public Map<String, CacheInfo> getCacheInfos() {
+  public Map<String, CacheInfo> getCacheInfos(
+      Predicate<String> nameFilter, boolean includeDiskStats) {
     Map<String, CacheInfo> cacheInfos = new TreeMap<>();
     for (Extension<Cache<?, ?>> e : cacheMap) {
-      cacheInfos.put(
-          cacheNameOf(e.getPluginName(), e.getExportName()),
-          CacheInfoFactory.create(e.getProvider().get()));
+      String name = cacheNameOf(e.getPluginName(), e.getExportName());
+      if (nameFilter.test(name)) {
+        cacheInfos.put(name, CacheInfoFactory.create(e.getProvider().get(), includeDiskStats));
+      }
     }
     return cacheInfos;
   }
@@ -73,7 +86,7 @@
   @Override
   public Response<Object> apply(ConfigResource rsrc) {
     if (format == null) {
-      return Response.ok(getCacheInfos());
+      return Response.ok(getCacheInfos(name -> true, includeDiskStats));
     }
     Stream<String> cacheNames =
         Streams.stream(cacheMap)
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
index 11d8b19..abbe50d 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -77,8 +77,8 @@
       if (newBranch.isEmpty()) {
         continue;
       }
-      if (!RefPattern.isRE(newBranch) && !newBranch.startsWith(RefNames.REFS)) {
-        newBranch = RefNames.REFS_HEADS + newBranch;
+      if (!RefPattern.isRE(newBranch)) {
+        newBranch = RefNames.fullName(newBranch);
       }
       try {
         RefPattern.validate(newBranch);
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
index 4e9d432..177f26d 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -27,9 +27,9 @@
 import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Map;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
index 71080a5..2c7bfce 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
-import javax.inject.Inject;
-import javax.inject.Singleton;
+import com.google.inject.Singleton;
 
 @Singleton
 public class PostSubmitRequirements
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
index f0a371a..c9485fc 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
index 7e6cc19..eda92bb 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
index 62c88276..d386f85 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -49,10 +49,10 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Inject;
-import javax.inject.Provider;
-import javax.inject.Singleton;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
index 6be71f8..4cc30ec 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import javax.inject.Inject;
 
 /** Implementation of {@link PrologSubmitRuleUtil}. */
 @Singleton
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index 813bee9..fad29a7 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
-import javax.inject.Inject;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Update superprojects after submission is done */
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 7a958b7..bf7c154 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -45,8 +45,13 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.mina.MinaSession;
@@ -79,6 +84,17 @@
   @Option(name = "--show-threads", usage = "show detailed thread counts")
   private boolean showThreads;
 
+  @Option(
+      name = "--include-diskstats",
+      usage = "include disk stat collection for persistent caches")
+  private boolean includeDiskStats;
+
+  @Option(
+      name = "--cache",
+      usage = "show the named cache; may be supplied more than once",
+      metaVar = "NAME")
+  private List<String> caches = new ArrayList<>();
+
   @Inject private SshDaemon daemon;
   @Inject private ListCaches listCaches;
   @Inject private GetSummary getSummary;
@@ -153,14 +169,21 @@
   }
 
   private Collection<CacheInfo> getCaches() {
-    @SuppressWarnings("unchecked")
-    Map<String, CacheInfo> caches =
-        (Map<String, CacheInfo>) listCaches.apply(new ConfigResource()).value();
-    for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
+    Map<String, CacheInfo> selected;
+    if (caches.isEmpty()) {
+      selected = listCaches.getCacheInfos(name -> true, includeDiskStats);
+    } else {
+      Set<String> filter =
+          caches.stream().map(n -> n.toLowerCase(Locale.US)).collect(Collectors.toSet());
+      selected =
+          listCaches.getCacheInfos(
+              n -> filter.contains(n.toLowerCase(Locale.US)), includeDiskStats);
+    }
+    for (Map.Entry<String, CacheInfo> entry : selected.entrySet()) {
       CacheInfo cache = entry.getValue();
       cache.name = entry.getKey();
     }
-    return caches.values();
+    return selected.values();
   }
 
   private void memSummary(MemSummaryInfo memSummary) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java
index 7b2e2dc..e15741e 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java
@@ -104,6 +104,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountStateInfo;
@@ -3473,4 +3474,104 @@
       return ImmutableList.copyOf(metadataList);
     }
   }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void queryAccountsVisibilitySameGroup() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // Switch to user context (user and user2 are not in the same group by default)
+    requestScopeOperations.setApiUser(user.id());
+
+    // Querying for user2 should return nothing
+    List<AccountInfo> result = gApi.accounts().query("email:" + user2.email()).get();
+    assertThat(result).isEmpty();
+
+    // Querying for user2 with details should also return nothing (reproduces Bypass 1)
+    result =
+        gApi.accounts()
+            .query("email:" + user2.email())
+            .withOption(ListAccountsOption.DETAILS)
+            .get();
+    assertThat(result).isEmpty();
+
+    // Suggesting for user2 should also return nothing (reproduces Bypass 2)
+    result = gApi.accounts().suggestAccounts(user2.email()).get();
+    assertThat(result).isEmpty();
+
+    // Querying for self should still work
+    result = gApi.accounts().query("email:" + user.email()).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0)._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void queryAccountsVisibilityNone() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Querying for user2 should return nothing
+    List<AccountInfo> result = gApi.accounts().query("email:" + user2.email()).get();
+    assertThat(result).isEmpty();
+
+    // Querying for self should still return self
+    result = gApi.accounts().query("email:" + user.email()).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0)._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void queryAccountsAnonymousUserSameGroup() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    // Anonymous user is not in same group as user, so query should return empty list
+    List<AccountInfo> result =
+        gApi.accounts().query("email:" + user.email()).withOption(ListAccountsOption.DETAILS).get();
+    assertThat(result).isEmpty();
+
+    // Suggest should also return empty list
+    result = gApi.accounts().suggestAccounts(user.email()).get();
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
+  public void queryAccountsAnonymousUserVisibleGroup() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    // Anonymous user cannot see user, so query should return empty list
+    List<AccountInfo> result =
+        gApi.accounts().query("email:" + user.email()).withOption(ListAccountsOption.DETAILS).get();
+    assertThat(result).isEmpty();
+
+    // Suggest should also return empty list
+    result = gApi.accounts().suggestAccounts(user.email()).get();
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "ALL")
+  public void queryAccountsAnonymousUserVisibilityAll() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    // Query with details should return details (name, email)
+    List<AccountInfo> result =
+        gApi.accounts().query("email:" + user.email()).withOption(ListAccountsOption.DETAILS).get();
+    assertThat(result).isNotEmpty();
+    for (AccountInfo info : result) {
+      assertThat(info.name).isEqualTo(user.fullName());
+      assertThat(info.email).isEqualTo(user.email());
+    }
+
+    // Suggest should return details (name, email)
+    result = gApi.accounts().suggestAccounts(user.email()).get();
+    assertThat(result).isNotEmpty();
+    for (AccountInfo info : result) {
+      assertThat(info.name).isEqualTo(user.fullName());
+      assertThat(info.email).isEqualTo(user.email());
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
index 984b32d..a1a6755 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
 import java.time.Instant;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 8fc4c65..44190f8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
@@ -69,6 +70,7 @@
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
@@ -804,6 +806,30 @@
   }
 
   @Test
+  public void changeInfoStatusReflectsSynchronousSubmitByCommentAddedListener() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CommentAddedListener submittingListener =
+        event -> {
+          try {
+            gApi.changes()
+                .id(event.getChange().project, event.getChange()._number)
+                .current()
+                .submit();
+          } catch (RestApiException e) {
+            throw new RuntimeException(e);
+          }
+        };
+
+    try (Registration registration = extensionRegistry.newRegistration().add(submittingListener)) {
+      ReviewResult reviewResult =
+          gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+
+      assertThat(reviewResult.changeInfo.status).isEqualTo(ChangeStatus.MERGED);
+    }
+  }
+
+  @Test
   public void submitRulesAreInvokedOnlyOnce() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index a987225..7f2e39e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -21,6 +21,7 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.extensions.common.CacheInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.Arrays;
@@ -88,4 +89,36 @@
   public void listCaches_BadRequest() throws Exception {
     adminRestSession.get("/config/server/caches/?format=NONSENSE").assertBadRequest();
   }
+
+  @Test
+  public void listCaches_withoutIncludeDiskStats_memCacheUnaffected() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/");
+    r.assertOK();
+    Map<String, CacheInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
+
+    assertThat(result).containsKey("accounts");
+    CacheInfo accountsCacheInfo = result.get("accounts");
+    assertThat(accountsCacheInfo.type).isEqualTo(CacheInfo.CacheType.MEM);
+    assertThat(accountsCacheInfo.entries.mem).isAtLeast(1L);
+    assertThat(accountsCacheInfo.hitRatio.mem).isAtLeast(0);
+
+    assertThat(accountsCacheInfo.entries.disk).isNull();
+    assertThat(accountsCacheInfo.hitRatio.disk).isNull();
+  }
+
+  @Test
+  @UseLocalDisk
+  public void listCaches_withIncludeDiskStats_diskCacheHasDiskStats() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/?include-diskstats=true");
+    r.assertOK();
+    Map<String, CacheInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
+
+    assertThat(result).containsKey("accounts");
+    CacheInfo accountsInfo = result.get("accounts");
+    assertThat(accountsInfo.type).isEqualTo(CacheInfo.CacheType.DISK);
+    assertThat(accountsInfo.entries.mem).isNotNull();
+    assertThat(accountsInfo.entries.disk).isNotNull();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 0e13732..8cf8206 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -51,10 +51,10 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.util.Collection;
 import java.util.stream.StreamSupport;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
index 4ad1caa..f0dd3a7 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -42,8 +42,8 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index d10d559..2418172 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -26,9 +26,9 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.Optional;
-import javax.inject.Inject;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 945c0b9..52a380f 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -1014,6 +1014,29 @@
     assertThat(srResult.errorMessage().get()).isEqualTo("Invalid content pattern.");
   }
 
+  @Test
+  public void invalidPathRegex_returnsErrorExpressionResult() throws Exception {
+    SubmitRequirementExpression exp = SubmitRequirementExpression.create("path:\"^foo[\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).contains("unexpected end-of-string");
+  }
+
+  @Test
+  public void invalidPathRegex_returnsErrorRequirementResult() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "path:\"^foo[\"",
+            /* overrideExpr= */ null);
+
+    SubmitRequirementResult srResult = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult().get().errorMessage().get())
+        .contains("unexpected end-of-string");
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
index f6e5fb3..107d241 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
-import javax.inject.Inject;
+import com.google.inject.Inject;
 import org.junit.ClassRule;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
index acb2e5a..d7afb69 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
-import javax.inject.Inject;
+import com.google.inject.Inject;
 import org.junit.ClassRule;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
index 5dd6f01..36ea5d1 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.lucene.LuceneChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
-import javax.inject.Inject;
+import com.google.inject.Inject;
 import org.junit.ClassRule;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
index f012ee3..ab464e8 100644
--- a/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/RemoteUserUtilTest.java
@@ -15,11 +15,45 @@
 package com.google.gerrit.httpd;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.RemoteUserUtil.PROXY_REMOTE_ADDRESS_ATTR;
 import static com.google.gerrit.httpd.RemoteUserUtil.extractUsername;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.when;
 
+import com.google.common.base.Suppliers;
+import com.google.common.net.HttpHeaders;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.inject.ProvisionException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Set;
+import java.util.function.Supplier;
+import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class RemoteUserUtilTest {
+  private static final String CUSTOM_LOGIN_HEADER = "MY_HEADER";
+  private static final String EXPECTED_USER = "user";
+  private static final String BASIC_AUTHENTICATION_USER_HEADER =
+      "Basic "
+          + Base64.getEncoder()
+              .encodeToString((EXPECTED_USER + ":pass").getBytes(StandardCharsets.UTF_8));
+
+  private Supplier<RemoteUserUtil> remoteUserUtil;
+
+  @Mock AuthConfig authConfigMock;
+
+  @Before
+  public void setup() {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of());
+    remoteUserUtil = Suppliers.memoize(() -> new RemoteUserUtil(authConfigMock));
+  }
+
   @Test
   public void testExtractUsername() {
     assertThat(extractUsername(null)).isNull();
@@ -27,4 +61,165 @@
     assertThat(extractUsername("Basic dXNlcjpwYXNzd29yZA==")).isEqualTo("user");
     assertThat(extractUsername("Digest username=\"user\", realm=\"test\"")).isEqualTo("user");
   }
+
+  @Test
+  public void testExtractUserFromRequestWithCustomHeaderAllowedByDefault() throws Exception {
+    FakeHttpServletRequest fakeRequest = new FakeHttpServletRequest();
+    fakeRequest.addHeader(CUSTOM_LOGIN_HEADER, EXPECTED_USER);
+    assertThat(remoteUserUtil.get().getRemoteUser(fakeRequest, CUSTOM_LOGIN_HEADER))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithAuthenticationHeaderAllowedByDefault()
+      throws Exception {
+    FakeHttpServletRequest fakeRequest = new FakeHttpServletRequest();
+    fakeRequest.addHeader(HttpHeaders.AUTHORIZATION, BASIC_AUTHENTICATION_USER_HEADER);
+    assertThat(remoteUserUtil.get().getRemoteUser(fakeRequest, HttpHeaders.AUTHORIZATION))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithCustomHeaderAllowedUsingProxyExactIPv4Matching()
+      throws Exception {
+    String clientIP = "192.168.1.2";
+    String proxyId = "80.78.1.3";
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of(proxyId + "/32"));
+    FakeHttpServletRequest fakeRequest = newFakeHttpRequest(clientIP, EXPECTED_USER);
+    fakeRequest.setAttribute(PROXY_REMOTE_ADDRESS_ATTR, proxyId);
+    assertThat(remoteUserUtil.get().getRemoteUser(fakeRequest, CUSTOM_LOGIN_HEADER))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithCustomHeaderAllowedWithExactIPv4Matching()
+      throws Exception {
+    String remoteIp = "192.168.1.2";
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of(remoteIp + "/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(newFakeHttpRequest(remoteIp, EXPECTED_USER), CUSTOM_LOGIN_HEADER))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithAuthenticationHeaderAllowedWithExactIPv4Matching()
+      throws Exception {
+    String remoteIp = "192.168.1.2";
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of(remoteIp + "/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(
+                    newFakeAuthHttpRequest(remoteIp, BASIC_AUTHENTICATION_USER_HEADER),
+                    HttpHeaders.AUTHORIZATION))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithCustomHeaderAllowedWithExactIPv4InAcceptedRange()
+      throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks())
+        .thenReturn(Set.of("10.16.0.0/16", "192.168.1.0/24", "8.8.8.8/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(newFakeHttpRequest("10.16.5.1", EXPECTED_USER), CUSTOM_LOGIN_HEADER))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void
+      testExtractUserFromRequestWithAuthenticationHeaderAllowedWithExactIPv4InAcceptedRange()
+          throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks())
+        .thenReturn(Set.of("10.16.0.0/16", "192.168.1.0/24", "8.8.8.8/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(
+                    newFakeAuthHttpRequest("10.16.5.1", BASIC_AUTHENTICATION_USER_HEADER),
+                    HttpHeaders.AUTHORIZATION))
+        .isEqualTo(EXPECTED_USER);
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithCustomHeaderRejectedWithNonMatchingExactIPv4()
+      throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of("2.2.2.2/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(newFakeHttpRequest("1.1.1.1", EXPECTED_USER), CUSTOM_LOGIN_HEADER))
+        .isNull();
+  }
+
+  @Test
+  public void testExtractUserFromRequestWithAuthenticationHeaderRejectedWithNonMatchingExactIPv4()
+      throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of("2.2.2.2/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(
+                    newFakeAuthHttpRequest("1.1.1.1", BASIC_AUTHENTICATION_USER_HEADER),
+                    HttpHeaders.AUTHORIZATION))
+        .isNull();
+  }
+
+  @Test
+  public void testExtractUserFromRequestRejectedWithIPv6() throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of("255.255.255.255/32"));
+    assertThat(
+            remoteUserUtil
+                .get()
+                .getRemoteUser(
+                    newFakeHttpRequest("2001:0db8:85a3:0000:0000:8a2e:0370:7334", "user"),
+                    CUSTOM_LOGIN_HEADER))
+        .isNull();
+  }
+
+  @Test
+  public void testFailWhenUsingAnInvalidProxyNetworkCIDR() throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of("invalid-network"));
+    assertThrows(ProvisionException.class, () -> remoteUserUtil.get());
+  }
+
+  @Test
+  public void testFailWhenUsingSingleIPAsProxyNetworkCIDR() throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of("192.168.0.1"));
+    assertThrows(ProvisionException.class, () -> remoteUserUtil.get());
+  }
+
+  @Test
+  public void testFailWhenUsingIPv6AsProxyNetworkCIDR() throws Exception {
+    when(authConfigMock.getTrustedProxyNetworks()).thenReturn(Set.of("2000::/3"));
+    assertThrows(ProvisionException.class, () -> remoteUserUtil.get());
+  }
+
+  private static FakeHttpServletRequest newFakeHttpRequest(String remoteIp, String expectedUser) {
+    FakeHttpServletRequest fakeRequest =
+        new FakeHttpServletRequest() {
+          @Override
+          public String getRemoteAddr() {
+            return remoteIp;
+          }
+        };
+    fakeRequest.addHeader(CUSTOM_LOGIN_HEADER, expectedUser);
+    return fakeRequest;
+  }
+
+  private static FakeHttpServletRequest newFakeAuthHttpRequest(
+      String remoteIp, String basicAuthHeader) {
+    FakeHttpServletRequest fakeRequest =
+        new FakeHttpServletRequest() {
+          @Override
+          public String getRemoteAddr() {
+            return remoteIp;
+          }
+        };
+    fakeRequest.addHeader(HttpHeaders.AUTHORIZATION, basicAuthHeader);
+    return fakeRequest;
+  }
 }
diff --git a/javatests/com/google/gerrit/httpd/auth/container/HttpAuthFilterTest.java b/javatests/com/google/gerrit/httpd/auth/container/HttpAuthFilterTest.java
index a5f8349..32ae2ca 100644
--- a/javatests/com/google/gerrit/httpd/auth/container/HttpAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/auth/container/HttpAuthFilterTest.java
@@ -18,6 +18,7 @@
 import static org.mockito.Mockito.doReturn;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.AuthConfig;
@@ -37,13 +38,14 @@
   @Mock private DynamicItem<WebSession> webSession;
   @Mock private ExternalIdKeyFactory externalIdKeyFactory;
   @Mock private AuthConfig authConfig;
+  @Mock private RemoteUserUtil remoteUserUtil;
 
   @Test
   public void getRemoteDisplaynameShouldReturnDisplaynameHeaderWhenHeaderIsConfiguredAndSet()
       throws IOException {
     doReturn(DISPLAYNAME_HEADER).when(authConfig).getHttpDisplaynameHeader();
     HttpAuthFilter httpAuthFilter =
-        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory);
+        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory, remoteUserUtil);
 
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.addHeader(DISPLAYNAME_HEADER, DISPLAYNAME);
@@ -56,7 +58,7 @@
       throws IOException {
     doReturn(DISPLAYNAME_HEADER).when(authConfig).getHttpDisplaynameHeader();
     HttpAuthFilter httpAuthFilter =
-        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory);
+        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory, remoteUserUtil);
 
     FakeHttpServletRequest req = new FakeHttpServletRequest();
 
@@ -68,7 +70,7 @@
       throws IOException {
     doReturn(DISPLAYNAME_HEADER).when(authConfig).getHttpDisplaynameHeader();
     HttpAuthFilter httpAuthFilter =
-        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory);
+        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory, remoteUserUtil);
 
     FakeHttpServletRequest req = new FakeHttpServletRequest();
     req.addHeader(DISPLAYNAME_HEADER, "");
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
index fbd5890..9f55711 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
@@ -56,6 +56,7 @@
             DynamicMap.emptyMap(),
             null,
             null,
+            null,
             cacheDir,
             EnumSet.noneOf(CacheOptions.class),
             new AtomicBoolean(false));
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index cf8bdda..b4228b6 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -75,7 +75,9 @@
         refreshAfterWrite,
         true,
         true,
-        new AtomicBoolean(false));
+        new AtomicBoolean(false),
+        false,
+        null);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index ed179a7..bd32241 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -30,11 +30,11 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Key;
+import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.nio.file.Path;
-import javax.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 32444f7..0f09496 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3825,36 +3825,6 @@
     testByCommitsOnBranchNotMerged(project, ImmutableSet.of(missing));
   }
 
-  @Test
-  public void byCommitsOnBranchNotMergedPartitioned() throws Exception {
-    Project.NameKey project = Project.nameKey("repo");
-    createProject(project);
-    int n = 10;
-    List<String> shas = new ArrayList<>(n);
-    List<Integer> expectedIds = new ArrayList<>(n);
-    BranchNameKey dest = null;
-    try (TestRepository<Repository> repository =
-        new TestRepository<>(repoManager.openRepository(project))) {
-      for (int i = 0; i < n; i++) {
-        ChangeInserter ins = newChange(repository);
-        insert(project, ins);
-        if (dest == null) {
-          dest = ins.getChange().getDest();
-        }
-        shas.add(ins.getCommitId().name());
-        expectedIds.add(ins.getChange().getId().get());
-      }
-    }
-    try (Repository repository = repoManager.openRepository(project)) {
-      InternalChangeQuery query = queryProvider.get();
-      query.setBatchSizeForTesting(3); // Force partitioning
-      Iterable<ChangeData> cds = query.byCommitsOnBranchNotMerged(repository, dest, shas, 11);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
-      assertThat(ids).hasSize(n);
-      assertThat(ids).containsExactlyElementsIn(expectedIds);
-    }
-  }
-
   private void testByCommitsOnBranchNotMerged(Project.NameKey project, Collection<ObjectId> extra)
       throws Exception {
     int n = 10;
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
index 136938a..ac3998eb 100644
--- a/javatests/com/google/gerrit/testing/BUILD
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/guice",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/testing/GuiceErrorFormattingTest.java b/javatests/com/google/gerrit/testing/GuiceErrorFormattingTest.java
new file mode 100644
index 0000000..72e9da0
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/GuiceErrorFormattingTest.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2026 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.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.inject.ConfigurationException;
+import com.google.inject.Guice;
+import org.junit.Test;
+
+/**
+ * Regression test for the Guice bundled-ASM incompatibility with Java 25 class files (<a
+ * href="https://github.com/google/guice/issues/1926">guice#1926</a>).
+ *
+ * <p>When Guice formats the source location for a configuration error it reads the offending class
+ * with ASM. The ASM shaded into the default Guice jar cannot read Java 25 (class major version 69)
+ * bytecode: the read throws, Guice logs a warning, and the source silently degrades to {@code
+ * (Unknown Source)}. Building against the {@code classes} classifier plus an external OW2 ASM makes
+ * the read succeed, so the error names the real {@code File.java:line}.
+ *
+ * <p>The check below therefore asserts on the resolved source (present only when ASM works), not on
+ * the swallowed log line — otherwise it would pass with or without the fix.
+ */
+public class GuiceErrorFormattingTest {
+  @Test
+  public void configurationErrorResolvesSourceLocationOnJava25() {
+    ConfigurationException thrown =
+        assertThrows(
+            ConfigurationException.class,
+            () -> Guice.createInjector().getInstance(MissingBinding.class));
+
+    // Sanity: this is the missing-constructor error we set out to provoke.
+    assertThat(thrown).hasMessageThat().contains("No injectable constructor for type");
+
+    // The guard: source formatting must resolve the class's real file:line via ASM. With the
+    // bundled Guice ASM on Java 25 the read fails and the source degrades to "(Unknown Source)".
+    assertThat(thrown).hasMessageThat().contains("GuiceErrorFormattingTest.java:");
+    assertThat(thrown).hasMessageThat().doesNotContain("Unknown Source");
+  }
+
+  private static class MissingBinding {
+    @SuppressWarnings("UnusedMethod")
+    MissingBinding(String value) {
+      throw new AssertionError(value);
+    }
+  }
+}
diff --git a/lib/BUILD b/lib/BUILD
index d8582c4..5c7efa2 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -81,7 +81,7 @@
     name = "jgit-servlet",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = ["@jgit//org.eclipse.jgit.http.server:jgit-servlet"],
+    exports = ["@jgit//org.eclipse.jgit.http.server.ee8:jgit-servlet-ee8"],
     runtime_deps = [":jgit"],
 )
 
@@ -211,6 +211,13 @@
 )
 
 java_library(
+    name = "jspecify",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@external_deps//:org_jspecify_jspecify"],
+)
+
+java_library(
     name = "blame-cache",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 8e4272b..12151cd 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -15,7 +15,10 @@
     name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@external_deps//:com_google_inject_guice"],
+    exports = [
+        "//lib/ow2:ow2-asm",
+        "@external_deps//:com_google_inject_guice_classes",
+    ],
     runtime_deps = ["aopalliance"],
 )
 
diff --git a/modules/jgit b/modules/jgit
index 516c694..bf0f0ad 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 516c69459c8e9aefc292aa938e76203abef3e8ce
+Subproject commit bf0f0ad1cc2ce422604383272a4a4b8b3947b465
diff --git a/package.json b/package.json
index 97acc45..75a1a9f 100644
--- a/package.json
+++ b/package.json
@@ -66,11 +66,11 @@
     "test:single:coverage": "yarn --cwd=polygerrit-ui test:single:coverage",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
-    "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "eslintfix:modified": "git diff --name-only --diff-filter=d | grep -E 'polygerrit-ui/app/.*\\.(js|ts)$' | sed 's|^polygerrit-ui/app/||' | xargs -r npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix",
+    "eslintfix": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app --fix --cache",
+    "eslintfix:modified": "git diff --name-only --diff-filter=d | grep -E 'polygerrit-ui/app/.*\\.(js|ts)$' | xargs -r eslint -c polygerrit-ui/app/eslint-bazel.config.js --fix",
     "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
     "litlintforCI": "lit-analyzer --strict --rules.no-unknown-property off --rules.no-unknown-tag-name off --rules.no-incompatible-type-binding off --rules.no-incompatible-property-type off --rules.no-invalid-tag-name off --rules.no-property-visibility-mismatch off --rules.no-unknown-attribute off **/elements/**/*.ts",
-    "lint": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app",
+    "lint": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app --cache",
     "gjf": "./tools/gjf.sh run"
   },
   "repository": {
diff --git a/plugins/BUILD b/plugins/BUILD
index b831b55..7af6fd2 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -91,12 +91,12 @@
     "//lib/guice:guice",
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
-    "//lib/guice:javax_inject",
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
     "//lib:jgit-servlet",
     "//lib:jgit",
     "//lib:jsr305",
+    "//lib:jspecify",
     "//lib/log:api",
     "//lib/log:log4j",
     "//lib/mina:sshd",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 35d2770..0b27d4f 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 35d277049e0a5af38e42684fcd21093f77f72748
+Subproject commit 0b27d4f966d89622b44d2c16b96379f37f08ff64
diff --git a/plugins/delete-project b/plugins/delete-project
index b892353..3603583 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit b8923537908122b610d263b480c2f8624126be26
+Subproject commit 3603583484c45e578aeff66885723dfd171932fa
diff --git a/plugins/gitiles b/plugins/gitiles
index ef5e73c..0e3ccb9 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit ef5e73cbc0182e0cbb71ccd22143bf6b02736fdb
+Subproject commit 0e3ccb926a23b972d9f4472e3ba7d874fac481c8
diff --git a/plugins/package.json b/plugins/package.json
index efd7a90..9e4737b 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -34,6 +34,7 @@
     "@gerritcodereview/typescript-api": "3.14.0",
     "@material/web": "^2.4.1",
     "@open-wc/testing": "^4.0.0",
+    "@types/sinon": "^17.0.4",
     "@web/dev-server-esbuild": "^1.0.4",
     "@web/test-runner": "^0.20.2",
     "lit": "^3.3.1",
diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml
index a2aca79..1c99c8c 100644
--- a/plugins/pnpm-lock.yaml
+++ b/plugins/pnpm-lock.yaml
@@ -101,6 +101,9 @@
       '@open-wc/testing':
         specifier: ^4.0.0
         version: 4.0.0
+      '@types/sinon':
+        specifier: ^17.0.4
+        version: 17.0.4
       '@web/dev-server-esbuild':
         specifier: ^1.0.4
         version: 1.0.4
diff --git a/plugins/replication b/plugins/replication
index 681d9ab..07782a0 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 681d9ab03db4d2ac3b7bfc81f64e490ddaceebfb
+Subproject commit 07782a0f6bee9e78546d250abd59dd099849a0a8
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 551be7b..26b3007 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -491,10 +491,10 @@
   dependencies:
     "@types/chai" "^4.2.12"
 
-"@gerritcodereview/typescript-api@3.13.0":
-  version "3.13.0"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.13.0.tgz#c7f5ac20f8b5b575424508eb1deb260a290ca0f1"
-  integrity sha512-1TvqARSNO9SXnnCzJKZVz9SFTr4hx1ZzBR0jyRZM6Xd4lLw3YwCfXozD9E8Pm4pfBWmN9WDkURboEG85p7ICag==
+"@gerritcodereview/typescript-api@3.14.0":
+  version "3.14.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.14.0.tgz#0ba1a06b68593e84e5a7525baf91521fdbcb2920"
+  integrity sha512-GhYzh6h/bHUz2cU1pzHwlfWDeGSkGAlBulfvrUyG4N3tuA/jqUp5LSBDeeJHzXgmOSdNE7Qyravjh3wDd0REyg==
 
 "@hapi/bourne@^3.0.0":
   version "3.0.0"
@@ -1170,7 +1170,7 @@
     "@types/chai" "*"
     "@types/sinon" "*"
 
-"@types/sinon@*":
+"@types/sinon@*", "@types/sinon@^17.0.4":
   version "17.0.4"
   resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.4.tgz#fd9a3e8e07eea1a3f4a6f82a972c899e5778f369"
   integrity sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index 7cf200f..38c8141 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -5,6 +5,7 @@
  */
 import {
   CoverageRange,
+  DiffLayer,
   FileRange,
   GrDiff,
   TokenHighlightEventDetails,
@@ -39,6 +40,11 @@
   highlight?: TokenHighlightEventDetails
 ) => void;
 
+/**
+ * Factory function to create a DiffLayer.
+ */
+export type DiffLayerFactory = (details: DiffDetails) => DiffLayer;
+
 export declare interface AnnotationPluginApi {
   /**
    * The specified function will be called when a gr-diff component is built,
@@ -56,8 +62,11 @@
    *
    * The callback receives details of the diff itself and of the highlighted
    * token.
-   *
-   * TODO: Replace with a more general addDiffLayer() endpoint.
    */
   addTokenHoverListener(callback: TokenHoverListener): void;
+
+  /**
+   * Register a factory that creates a DiffLayer for each diff view.
+   */
+  addDiffLayer(factory: DiffLayerFactory): void;
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 50570f7..45ca607 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -385,11 +385,6 @@
   path?: string;
 }
 
-// TODO: Currently unused and not fired.
-export declare interface RenderProgressEventDetail {
-  linesRendered: number;
-}
-
 /**
  * The detail of the 'copy-info' event dispatched by gr-diff.
  */
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index a683f48..32e77ae 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -192,9 +192,9 @@
 
 /**
  * EventDetails to be passed to the reportInteraction method for AI agent
- * interactions.
+ * chat interactions.
  */
-export type AiAgentEventDetails = {
+export type AiAgentChatEventDetails = {
   agentId: string;
   conversationId: string;
   // Each agent response in a conversation is a turn.
@@ -206,3 +206,26 @@
   // Unique ID of the saved comment draft promoted from the suggestion.
   commentId?: string;
 };
+
+/**
+ * EventDetails to be passed to the reportInteraction method for AI agent
+ * check interactions.
+ */
+export type AiAgentCheckEventDetails = {
+  // The name of the check that was run.
+  checkName: string;
+  // The description from the check
+  checkDescription: string;
+  // The raw external id for the run result
+  externalId: string;
+  // Unique ID of the saved comment draft promoted from the suggestion.
+  commentId?: string;
+};
+
+/**
+ * EventDetails to be passed to the reportInteraction method for AI agent
+ * interactions.
+ */
+export type AiAgentEventDetails =
+  | AiAgentChatEventDetails
+  | AiAgentCheckEventDetails;
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 5f274bc..b35a0ab 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
@@ -1213,6 +1213,15 @@
         continue;
       }
       const status = this.getLabelStatus(labelInfo);
+      if (status === LabelStatus.REJECT || status === LabelStatus.IMPOSSIBLE) {
+        return null;
+      }
+      if (
+        label === StandardLabels.PRESUBMIT_VERIFIED ||
+        label.toLowerCase().startsWith('presubmit')
+      ) {
+        continue;
+      }
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so check if Code Review can be
@@ -1221,11 +1230,6 @@
           break;
         }
         result = label;
-      } else if (
-        status === LabelStatus.REJECT ||
-        status === LabelStatus.IMPOSSIBLE
-      ) {
-        return null;
       }
     }
     // Allow the user to use quick approve to vote the max score on code review
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 c571675..a8a31c6 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
@@ -2561,6 +2561,57 @@
         );
         assert.isNotOk(approveButton);
       });
+
+      test('ignore presubmit labels for quick approve', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: createAccountWithId(1),
+              all: [{value: 2}],
+            },
+            'Presubmit-Verified': {
+              values: {'-1': '', ' 0': '', '+1': ''},
+            },
+          },
+          permitted_labels: {
+            'Presubmit-Verified': ['-1', ' 0', '+1'],
+          },
+        };
+        await element.updateComplete;
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('rejected presubmit label blocks quick approve', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              values: {'-2': '', '-1': '', ' 0': '', '+1': '', '+2': ''},
+            },
+            'Presubmit-Verified': {
+              rejected: createAccountWithId(2),
+              values: {'-1': '', ' 0': '', '+1': ''},
+            },
+          },
+          permitted_labels: {
+            'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+            'Presubmit-Verified': ['-1', ' 0', '+1'],
+          },
+        };
+        await element.updateComplete;
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
     });
 
     test('adds download revision action', async () => {
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 4439344..d9de5f4e 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
@@ -579,6 +579,7 @@
       .statusOrCategory=${statusOrCategory}
       .text=${text}
       .links=${links}
+      .isAi=${!!run.isAiPowered}
       @click=${handler}
       @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
index 1559a1c..045186e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
@@ -52,11 +52,13 @@
       createRun({
         status: RunStatus.COMPLETED,
         checkName: 'info-check',
+        isAiPowered: true,
         results: [createCheckResult({category: Category.INFO})],
       }),
       createRun({
         status: RunStatus.COMPLETED,
         checkName: 'warning-check',
+        isAiPowered: true,
         results: [createCheckResult({category: Category.WARNING})],
       }),
       createRun({
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 5a1e855..9744398 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
@@ -264,6 +264,92 @@
     });
   });
 
+  suite('ai chips', () => {
+    test('detailed chip has isAi set based on run.isAiPowered', async () => {
+      element.runs = [
+        createRun({
+          checkName: 'AI Check',
+          status: RunStatus.COMPLETED,
+          isAiPowered: true,
+          results: [createCheckResult({category: Category.ERROR})],
+        }),
+        createRun({
+          checkName: 'Normal Check',
+          status: RunStatus.COMPLETED,
+          isAiPowered: false,
+          results: [createCheckResult({category: Category.ERROR})],
+        }),
+      ];
+      element.showChecksSummary = true;
+      await element.updateComplete;
+
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      assert.equal(chips.length, 2);
+      assert.isTrue(chips[0].isAi);
+      assert.equal(chips[0].text, 'AI Check');
+      assert.isFalse(chips[1].isAi);
+      assert.equal(chips[1].text, 'Normal Check');
+    });
+
+    test('collapsed chip does not have isAi=true even if run is AI powered', async () => {
+      element.runs = [
+        createRun({
+          status: RunStatus.COMPLETED,
+          isAiPowered: true,
+          results: [createCheckResult({category: Category.SUCCESS})],
+        }),
+        createRun({
+          status: RunStatus.COMPLETED,
+          isAiPowered: false,
+          results: [createCheckResult({category: Category.SUCCESS})],
+        }),
+        createRun({status: RunStatus.RUNNING}),
+      ];
+      element.showChecksSummary = true;
+      await element.updateComplete;
+
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      const successChip = [...chips].find(
+        c => c.statusOrCategory === Category.SUCCESS
+      );
+      assert.isDefined(successChip);
+      assert.isFalse(successChip.isAi);
+      assert.equal(successChip.text, '2');
+    });
+
+    test('plus-more chip does not have isAi=true even if overflow run is AI powered', async () => {
+      const runs: CheckRun[] = [];
+      for (let i = 0; i < 8; i++) {
+        runs.push(
+          createRun({
+            checkName: `Error ${i}`,
+            status: RunStatus.COMPLETED,
+            isAiPowered: false,
+            results: [createCheckResult({category: Category.ERROR})],
+          })
+        );
+      }
+      runs.push(
+        createRun({
+          checkName: 'Error AI',
+          status: RunStatus.COMPLETED,
+          isAiPowered: true,
+          results: [createCheckResult({category: Category.ERROR})],
+        })
+      );
+
+      element.runs = runs;
+      element.showChecksSummary = true;
+      await element.updateComplete;
+
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      assert.equal(chips.length, 8);
+      const plusMoreChip = chips[7];
+      assert.equal(plusMoreChip.text, '+ 2 more');
+      assert.isFalse(plusMoreChip.isAi);
+    });
+  });
+
   suite('flows summary', () => {
     test('renders', async () => {
       flowsModel.setState({
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
index 1875fa5..eedf452 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
@@ -28,6 +28,9 @@
   @property({type: Array})
   links: string[] = [];
 
+  @property({type: Boolean})
+  isAi = false;
+
   private readonly reporting = getAppContext().reportingService;
 
   static override get styles() {
@@ -79,6 +82,10 @@
         }
         gr-icon {
           font-size: var(--line-height-small);
+          --gr-icon-size: var(--line-height-small);
+        }
+        gr-icon.ai-sparkle {
+          margin-left: var(--spacing-xs);
         }
         .checksChip a gr-icon.launch {
           color: var(--link-color);
@@ -172,9 +179,14 @@
     // 15 is roughly the number of chars for the chip exceeding its 120px width.
     return html`
       ${this.text.length > 15
-        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, icon)}`
+        ? html` ${this.renderChip(
+            chipClassFullLength,
+            ariaLabel,
+            icon,
+            this.isAi
+          )}`
         : ''}
-      ${this.renderChip(chipClass, ariaLabel, icon)}
+      ${this.renderChip(chipClass, ariaLabel, icon, this.isAi)}
     `;
   }
 
@@ -191,12 +203,18 @@
     return `${label} for check ${this.text}`;
   }
 
-  private renderChip(clazz: string, ariaLabel: string, icon: ChecksIcon) {
+  private renderChip(
+    clazz: string,
+    ariaLabel: string,
+    icon: ChecksIcon,
+    isAi: boolean
+  ) {
     return html`
       <div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
         <gr-icon icon=${icon.name} ?filled=${!!icon.filled}></gr-icon>
         ${this.renderLinks()}
         <div class="text">${this.text}</div>
+        ${isAi ? html`<gr-icon icon="ai" class="ai-sparkle"></gr-icon>` : ''}
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
index 6816609..c020980 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
@@ -85,4 +85,26 @@
       `
     );
   });
+
+  test('renders AI icon', async () => {
+    element.text = 'AI Check';
+    element.statusOrCategory = Category.ERROR;
+    element.isAi = true;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          aria-label="error for check AI Check"
+          class="checksChip error font-small"
+          role="link"
+          tabindex="0"
+        >
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="text">AI Check</div>
+          <gr-icon icon="ai" class="ai-sparkle"></gr-icon>
+        </div>
+      `
+    );
+  });
 });
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 caa60af..bad9f1e 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
@@ -981,7 +981,7 @@
           flex: 1;
           overflow-x: hidden;
         }
-        .relatedChanges {
+        .commitAside {
           flex: 0 1 auto;
           overflow: hidden;
           padding: var(--spacing-l) 0;
@@ -1005,6 +1005,16 @@
           margin: var(--spacing-l) 0;
           padding: 0 var(--spacing-l);
         }
+        .commitAside gr-endpoint-decorator[name='change-view-commit-aside'] {
+          display: none;
+        }
+        .commitAside
+          gr-endpoint-decorator[name='change-view-commit-aside']:has(
+            :not(gr-endpoint-param):not([hidden])
+          ) {
+          display: block;
+          margin-bottom: var(--spacing-l);
+        }
         .showOnEdit {
           display: none;
         }
@@ -1055,10 +1065,10 @@
           position: relative;
         }
         @media screen and (max-width: 75em) {
-          .relatedChanges {
+          .commitAside {
             padding: 0;
           }
-          .relatedChanges gr-related-changes-list {
+          .commitAside gr-related-changes-list {
             padding-top: var(--spacing-l);
           }
           #commitAndRelated {
@@ -1425,7 +1435,13 @@
               </gr-endpoint-param>
             </gr-endpoint-decorator>
           </div>
-          <div class="relatedChanges">
+          <div class="commitAside">
+            <gr-endpoint-decorator name="change-view-commit-aside">
+              <gr-endpoint-param name="change" .value=${this.change}>
+              </gr-endpoint-param>
+              <gr-endpoint-param name="revision" .value=${this.revision}>
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
             <gr-related-changes-list></gr-related-changes-list>
           </div>
           <div class="emptySpace"></div>
@@ -1923,7 +1939,7 @@
     const fileIndex = this.fileList.files.findIndex(f => f.__path === path);
     if (fileIndex !== -1) {
       this.fileList.fileCursor.setCursorAtIndex(fileIndex, true);
-      const isExpanded = this.fileList.expandedFiles.some(f => f.path === path);
+      const isExpanded = this.fileList.expandedFiles.has(path);
       if (!isExpanded) {
         this.fileList.toggleFileExpandedByIndex(fileIndex);
         await this.fileList.updateComplete;
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 26b6358..7e11970 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
@@ -355,7 +355,12 @@
                           </gr-endpoint-param>
                         </gr-endpoint-decorator>
                       </div>
-                      <div class="relatedChanges">
+                      <div class="commitAside">
+                        <gr-endpoint-decorator name="change-view-commit-aside">
+                          <gr-endpoint-param name="change"> </gr-endpoint-param>
+                          <gr-endpoint-param name="revision">
+                          </gr-endpoint-param>
+                        </gr-endpoint-decorator>
                         <gr-related-changes-list> </gr-related-changes-list>
                       </div>
                       <div class="emptySpace"></div>
@@ -1364,6 +1369,91 @@
     });
   });
 
+  suite('change-view-commit-aside visibility', () => {
+    let decorator: HTMLElement;
+
+    setup(async () => {
+      element.change = {...createChangeViewChange(), labels: {}};
+      element.revision = createRevision();
+      await element.updateComplete;
+      decorator = element.shadowRoot!.querySelector(
+        'gr-endpoint-decorator[name="change-view-commit-aside"]'
+      )!;
+    });
+
+    test('hidden by default', () => {
+      assert.equal(getComputedStyle(decorator).display, 'none');
+    });
+
+    test('hidden if plugin component is hidden', async () => {
+      const promise = mockPromise();
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
+      const plugin = (await promise) as PluginApi;
+
+      const dummyTagName = 'dummy-aside-component-hidden';
+      if (!customElements.get(dummyTagName)) {
+        customElements.define(
+          dummyTagName,
+          class extends HTMLElement {
+            connectedCallback() {
+              this.setAttribute('hidden', '');
+            }
+          }
+        );
+      }
+
+      plugin.registerCustomComponent('change-view-commit-aside', dummyTagName);
+
+      await new Promise<void>(resolve => {
+        const observer = new MutationObserver(() => {
+          if (decorator.querySelector(dummyTagName)) {
+            observer.disconnect();
+            resolve();
+          }
+        });
+        observer.observe(decorator, {childList: true});
+      });
+
+      await element.updateComplete;
+      assert.equal(getComputedStyle(decorator).display, 'none');
+    });
+
+    test('visible if plugin component is visible', async () => {
+      const promise = mockPromise();
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
+      const plugin = (await promise) as PluginApi;
+
+      const dummyTagName = 'dummy-aside-component-visible';
+      if (!customElements.get(dummyTagName)) {
+        customElements.define(dummyTagName, class extends HTMLElement {});
+      }
+
+      plugin.registerCustomComponent('change-view-commit-aside', dummyTagName);
+
+      await new Promise<void>(resolve => {
+        const observer = new MutationObserver(() => {
+          if (decorator.querySelector(dummyTagName)) {
+            observer.disconnect();
+            resolve();
+          }
+        });
+        observer.observe(decorator, {childList: true});
+      });
+
+      await element.updateComplete;
+      assert.equal(getComputedStyle(decorator).display, 'block');
+      assert.notEqual(getComputedStyle(decorator).marginBottom, '0px');
+    });
+  });
+
   test('handleToggleStar called when star is tapped', async () => {
     element.change = {
       ...createChangeViewChange(),
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 95df2fac..f808b1c 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
@@ -168,6 +168,7 @@
           .commands=${this.computeDownloadCommands()}
           .schemes=${this.schemes}
           .selectedScheme=${this.selectedScheme}
+          .disableAutoSelect=${true}
           show-keyboard-shortcut-tooltips
           @selected-scheme-changed=${(e: BindValueChangeEvent) => {
             this.selectedScheme = e.detail.value;
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 c107c20..438e151 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
@@ -122,6 +122,7 @@
       </section>
       <section class="hidden">
         <gr-download-commands
+          disable-auto-select=""
           id="downloadCommands"
           show-keyboard-shortcut-tooltips=""
         >
@@ -234,6 +235,14 @@
       await element.updateComplete;
     });
 
+    test('passes disableAutoSelect to gr-download-commands', () => {
+      const commands = queryAndAssert<GrDownloadCommands>(
+        element,
+        '#downloadCommands'
+      );
+      assert.isTrue(commands.disableAutoSelect);
+    });
+
     test('focuses on first copy link', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrDownloadCommands>(element, '#downloadCommands'),
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 dc28545..f255cb4 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
@@ -269,7 +269,7 @@
 
   // Private but used in tests.
   @state()
-  expandedFiles: PatchSetFile[] = [];
+  expandedFiles: Set<string> = new Set();
 
   // Private but used in tests.
   @state()
@@ -1976,29 +1976,26 @@
   }
 
   // private but used in test
-  toggleFileExpanded(file: PatchSetFile) {
+  toggleFileExpanded(path: string) {
     // Is the path in the list of expanded diffs? If so, remove it, otherwise
     // add it to the list.
-    const indexInExpanded = this.expandedFiles.findIndex(
-      f => f.path === file.path
-    );
-    if (indexInExpanded === -1) {
+    const newExpandedFiles = new Set(this.expandedFiles);
+    if (!newExpandedFiles.has(path)) {
       this.reporting.reportInteraction(Interaction.FILE_LIST_DIFF_EXPANDED);
-      this.expandedFiles = this.expandedFiles.concat([file]);
+      newExpandedFiles.add(path);
     } else {
       this.reporting.reportInteraction(Interaction.FILE_LIST_DIFF_COLLAPSED);
-      this.expandedFiles = this.expandedFiles.filter(
-        (_val, idx) => idx !== indexInExpanded
-      );
+      newExpandedFiles.delete(path);
     }
-    const indexInAll = this.files.findIndex(f => f.__path === file.path);
+    this.expandedFiles = newExpandedFiles;
+    const indexInAll = this.files.findIndex(f => f.__path === path);
     this.shadowRoot!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
       indexInAll
     ].scrollIntoView({block: 'nearest'});
   }
 
   toggleFileExpandedByIndex(index: number) {
-    this.toggleFileExpanded(this.computePatchSetFile(this.files[index]));
+    this.toggleFileExpanded(this.files[index].__path);
   }
 
   // Private but used in tests.
@@ -2007,23 +2004,22 @@
       return;
     }
     // Re-render all expanded diffs sequentially.
-    this.renderInOrder(this.expandedFiles, this.diffs);
+    this.renderInOrder([...this.expandedFiles], this.diffs);
   }
 
   expandAllDiffs() {
-    const newFiles = this.files
-      .slice(0, this.numFilesShown)
-      // TODO(b/419187980): Refactor expandedFiles to use a Set for efficiency.
-      .filter(file => !this.expandedFiles.some(f => f.path === file.__path))
-      .map(file => this.computePatchSetFile(file));
+    const newExpandedFiles = new Set(this.expandedFiles);
+    this.files.slice(0, this.numFilesShown).forEach(file => {
+      newExpandedFiles.add(file.__path);
+    });
 
     this.reporting.reportInteraction(Interaction.FILE_LIST_ALL_DIFFS_EXPANDED);
-    this.expandedFiles = newFiles.concat(this.expandedFiles);
+    this.expandedFiles = newExpandedFiles;
   }
 
   collapseAllDiffs() {
     this.reporting.reportInteraction(Interaction.FILE_LIST_ALL_DIFFS_COLLAPSED);
-    this.expandedFiles = [];
+    this.expandedFiles = new Set();
   }
 
   /**
@@ -2119,8 +2115,8 @@
     this.fileActionClick(e, file => this.reviewFile(file.path));
   }
 
-  private expandedClick(e: MouseEvent | KeyboardEvent) {
-    this.fileActionClick(e, file => this.toggleFileExpanded(file));
+  expandedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.toggleFileExpanded(file.path));
   }
 
   /**
@@ -2152,7 +2148,7 @@
 
     e.preventDefault();
     this.fileCursor.setCursor(fileRow.element);
-    this.toggleFileExpanded(file);
+    this.toggleFileExpanded(path);
   }
 
   private getFileRowFromEvent(e: Event): FileRow | null {
@@ -2424,7 +2420,7 @@
   }
 
   async filesChanged() {
-    if (this.expandedFiles.length > 0) this.expandedFiles = [];
+    if (this.expandedFiles.size > 0) this.expandedFiles = new Set();
     await this.updateCleanlyMergedPaths();
     if (!this.files || this.files.length === 0) return;
     await this.updateComplete;
@@ -2490,18 +2486,18 @@
     return val ? 'true' : 'false';
   }
 
-  private isFileExpanded(path: string | undefined) {
-    return this.expandedFiles.some(f => f.path === path);
+  isFileExpanded(path: string | undefined) {
+    return path !== undefined && this.expandedFiles.has(path);
   }
 
-  private isFileExpandedStr(path: string | undefined) {
+  isFileExpandedStr(path: string | undefined) {
     return this.booleanToString(this.isFileExpanded(path));
   }
 
   private computeExpandedFiles(): FilesExpandedState {
-    if (this.expandedFiles.length === 0) {
+    if (this.expandedFiles.size === 0) {
       return FilesExpandedState.NONE;
-    } else if (this.expandedFiles.length === this.files.length) {
+    } else if (this.expandedFiles.size === this.files.length) {
       return FilesExpandedState.ALL;
     }
     return FilesExpandedState.SOME;
@@ -2516,18 +2512,21 @@
    * @param newFiles The new files that have been added.
    * Private but used in tests.
    */
-  async expandedFilesChanged(oldFiles: Array<PatchSetFile>) {
+  async expandedFilesChanged(oldFiles?: Set<string>) {
     this.filesExpanded = this.computeExpandedFiles();
 
-    const newFiles = this.expandedFiles.filter(
-      file => (oldFiles ?? []).findIndex(f => f.path === file.path) === -1
-    );
+    const newPaths: string[] = [];
+    for (const path of this.expandedFiles) {
+      if (!oldFiles || !oldFiles.has(path)) {
+        newPaths.push(path);
+      }
+    }
 
     // Required so that the newly created diff view is included in this.diffs.
     await this.updateComplete;
 
-    if (newFiles.length) {
-      await this.renderInOrder(newFiles, this.diffs);
+    if (newPaths.length) {
+      await this.renderInOrder(newPaths, this.diffs);
     }
     this.updateDiffCursor();
     this.diffCursor?.reInitAndUpdateStops();
@@ -2542,11 +2541,10 @@
    *
    * @param initialCount The total number of paths in the pass.
    */
-  async renderInOrder(files: PatchSetFile[], diffElements: GrDiffHost[]) {
+  async renderInOrder(paths: string[], diffElements: GrDiffHost[]) {
     this.reporting.time(Timing.FILE_EXPAND_ALL);
 
-    for (const file of files) {
-      const path = file.path;
+    for (const path of paths) {
       const diffElem = this.findDiffByPath(path, diffElements);
       if (!diffElem) {
         this.reporting.error(
@@ -2558,8 +2556,7 @@
       diffElem.prefetchDiff();
     }
 
-    await asyncForeach(files, async (file, cancel) => {
-      const path = file.path;
+    await asyncForeach(paths, async (path, cancel) => {
       this.cancelForEachDiff = cancel;
 
       const diffElem = this.findDiffByPath(path, diffElements);
@@ -2582,7 +2579,7 @@
       if (
         this.loggedIn &&
         !this.diffPrefs.manual_review &&
-        files.length === 1
+        paths.length === 1
       ) {
         await this.reviewFile(path, true);
       }
@@ -2591,7 +2588,7 @@
 
     this.cancelForEachDiff = undefined;
     this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
-      count: files.length,
+      count: paths.length,
       height: this.clientHeight,
     });
     /*
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 be49bfa..4c6bf52 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
@@ -1067,41 +1067,41 @@
         element.fileCursor.setCursorAtIndex(0);
         await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element.expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.size, 0);
 
         pressKey(element, 'i');
         await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
-        assert.equal(element.expandedFiles.length, 1);
-        assert.equal(element.expandedFiles[0].path, paths[0]);
+        assert.equal(element.expandedFiles.size, 1);
+        assert.isTrue(element.expandedFiles.has(paths[0]));
 
         pressKey(element, 'i');
         await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element.expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.size, 0);
 
         element.fileCursor.setCursorAtIndex(1);
         pressKey(element, 'i');
         await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
-        assert.equal(element.expandedFiles.length, 1);
-        assert.equal(element.expandedFiles[0].path, paths[1]);
+        assert.equal(element.expandedFiles.size, 1);
+        assert.isTrue(element.expandedFiles.has(paths[1]));
 
         pressKey(element, 'I');
         await element.updateComplete;
         assert.equal(element.diffs.length, paths.length);
-        assert.equal(element.expandedFiles.length, paths.length);
+        assert.equal(element.expandedFiles.size, paths.length);
         for (const diff of element.diffs) {
-          assert.isTrue(element.expandedFiles.some(f => f.path === diff.path));
+          assert.isTrue(element.expandedFiles.has(diff.path!));
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
         pressKey(element, 'I');
         await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element.expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.size, 0);
       });
 
       test('r key sets reviewed flag', async () => {
@@ -1340,10 +1340,7 @@
       await element.updateComplete;
 
       assert.equal(showHideCheck!.getAttribute('aria-checked'), 'true');
-      assert.notEqual(
-        element.expandedFiles.findIndex(f => f.path === 'myfile.txt'),
-        -1
-      );
+      assert.isTrue(element.expandedFiles.has('myfile.txt'));
     });
 
     test('diff mode correctly toggles the diffs', async () => {
@@ -1413,8 +1410,8 @@
         queryAndAssert<GrIcon>(element, 'gr-icon').icon,
         'expand_more'
       );
-      assert.equal(element.expandedFiles.length, 0);
-      element.toggleFileExpanded({path});
+      assert.equal(element.expandedFiles.size, 0);
+      element.toggleFileExpanded(path);
       await element.updateComplete;
       // Wait for expandedFilesChanged to finish.
       await waitEventLoop();
@@ -1425,8 +1422,8 @@
       );
 
       assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element.expandedFiles.some(f => f.path === path));
-      element.toggleFileExpanded({path});
+      assert.isTrue(element.expandedFiles.has(path));
+      element.toggleFileExpanded(path);
       await element.updateComplete;
       // Wait for expandedFilesChanged to finish.
       await waitEventLoop();
@@ -1436,7 +1433,7 @@
         'expand_more'
       );
       assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element.expandedFiles.some(f => f.path === path));
+      assert.isFalse(element.expandedFiles.has(path));
     });
 
     test('expandAllDiffs and collapseAllDiffs', async () => {
@@ -1459,7 +1456,7 @@
       await element.updateComplete;
       // Wait for expandedFilesChanged to finish.
       await waitEventLoop();
-      assert.equal(element.expandedFiles.length, 0);
+      assert.equal(element.expandedFiles.size, 0);
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
     });
 
@@ -1490,7 +1487,7 @@
         },
       ];
       sinon.stub(element, 'diffs').get(() => diffs);
-      element.expandedFiles = element.expandedFiles.concat([{path}]);
+      element.expandedFiles = new Set([path]);
       await element.updateComplete;
       await waitEventLoop();
       await promise;
@@ -1500,12 +1497,12 @@
       element.files = [normalize({}, 'foo.bar'), normalize({}, 'baz.bar')];
       await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      element.expandedFiles.push({path: 'baz.bar'});
-      element.expandedFilesChanged([{path: 'baz.bar'}]);
+      element.expandedFiles.add('baz.bar');
+      element.expandedFilesChanged(new Set(['baz.bar']));
       await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-      element.expandedFiles.push({path: 'foo.bar'});
-      element.expandedFilesChanged([{path: 'foo.bar'}]);
+      element.expandedFiles.add('foo.bar');
+      element.expandedFilesChanged(new Set(['baz.bar', 'foo.bar']));
       await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
       element.collapseAllDiffs();
@@ -1552,10 +1549,7 @@
         },
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ] as any;
-      await element.renderInOrder(
-        [{path: 'p2'}, {path: 'p1'}, {path: 'p0'}],
-        diffs
-      );
+      await element.renderInOrder(['p2', 'p1', 'p0'], diffs);
       await element.updateComplete;
       assert.isFalse(reviewStub.called);
     });
@@ -1574,7 +1568,7 @@
         },
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ] as any;
-      await element.renderInOrder([{path: 'p2'}], diffs);
+      await element.renderInOrder(['p2'], diffs);
       await element.updateComplete;
       assert.equal(reviewStub.callCount, 1);
     });
@@ -1610,11 +1604,11 @@
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ] as any;
 
-      await element.renderInOrder([{path: 'p'}], diffs);
+      await element.renderInOrder(['p'], diffs);
       await element.updateComplete;
       assert.isFalse(reviewStub.called);
       delete element.diffPrefs.manual_review;
-      await element.renderInOrder([{path: 'p'}], diffs);
+      await element.renderInOrder(['p'], diffs);
       await element.updateComplete;
       // Wait for renderInOrder to finish
       await waitEventLoop();
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 03c79a8..65fc242 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
@@ -22,6 +22,7 @@
   VotingRangeInfo,
 } from '../../../types/common';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {isServiceUser} from '../../../utils/account-util';
 import {getVotingRange} from '../../../utils/label-util';
 import {
   FormattedReviewerUpdateInfo,
@@ -225,10 +226,28 @@
  * Autogenerated messages are unimportant, if there is a message with the same
  * tag and a higher revision number.
  */
+function isReviewerUpdateMessage(message: CombinedMessage): boolean {
+  return (
+    (message as FormattedReviewerUpdateInfo).type === 'REVIEWER_UPDATE' ||
+    message.tag === MessageTag.TAG_REVIEWER_UPDATE
+  );
+}
+
 function computeIsImportant(
   message: CombinedMessage,
   allMessages: CombinedMessage[]
 ) {
+  const author = message.author;
+  const realAuthor =
+    (message as ChangeMessageInfo).real_author ??
+    (message as FormattedReviewerUpdateInfo).realAuthor;
+  if (
+    (isServiceUser(author) || isServiceUser(realAuthor)) &&
+    isReviewerUpdateMessage(message)
+  ) {
+    return false;
+  }
+
   if (!message.tag) return true;
 
   const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
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 66a1951..8ec1bde 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
@@ -7,7 +7,7 @@
 import '../../../test/common-test-setup';
 import './gr-messages-list';
 import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
-import {MessageTag} from '../../../constants/constants';
+import {AccountTag, MessageTag} from '../../../constants/constants';
 import {
   query,
   queryAll,
@@ -59,7 +59,7 @@
   };
 };
 
-const randomMessage = function (params?: ChangeMessageInfo) {
+const randomMessage = function (params?: Partial<ChangeMessageInfo>) {
   params = params || ({} as ChangeMessageInfo);
   const author1 = {
     _account_id: 1115495 as AccountId,
@@ -488,6 +488,68 @@
       assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
     });
 
+    test('isImportant service user reviewer update vs other messages', () => {
+      const reviewerUpdateFromBot = {
+        ...randomMessage(),
+        author: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        type: 'REVIEWER_UPDATE' as const,
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+      };
+      const reviewerUpdateWithRealAuthorBot = {
+        ...randomMessage(),
+        author: {
+          _account_id: 456 as AccountId,
+        },
+        real_author: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        type: 'REVIEWER_UPDATE' as const,
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+      };
+      const formattedReviewerUpdateWithRealAuthorBot = {
+        author: {
+          _account_id: 456 as AccountId,
+        },
+        realAuthor: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        date: '2020-01-01 00:00:00.000000000' as Timestamp,
+        type: 'REVIEWER_UPDATE' as const,
+        tag: MessageTag.TAG_REVIEWER_UPDATE as const,
+        updates: [],
+      };
+      const commentFromBot = randomMessage({
+        author: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        message: 'Build succeeded: 10 tests passed',
+      });
+      assert.isFalse(
+        TEST_ONLY.computeIsImportant(reviewerUpdateFromBot, [
+          reviewerUpdateFromBot,
+        ])
+      );
+      assert.isFalse(
+        TEST_ONLY.computeIsImportant(reviewerUpdateWithRealAuthorBot, [
+          reviewerUpdateWithRealAuthorBot,
+        ])
+      );
+      assert.isFalse(
+        TEST_ONLY.computeIsImportant(formattedReviewerUpdateWithRealAuthorBot, [
+          formattedReviewerUpdateWithRealAuthorBot,
+        ])
+      );
+      assert.isTrue(
+        TEST_ONLY.computeIsImportant(commentFromBot, [commentFromBot])
+      );
+    });
+
     test('isImportant is evaluated after tag update', async () => {
       const m1 = randomMessage({
         ...randomMessage(),
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
index 2c7ae69..5618b06 100644
--- a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
@@ -26,7 +26,7 @@
 import {chatProvider, createChange} from '../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../types/types';
 import {CommentsModel} from '../../models/comments/comments-model';
-import {AiAgentEventDetails, Interaction} from '../../constants/reporting';
+import {AiAgentChatEventDetails, Interaction} from '../../constants/reporting';
 import {getAppContext} from '../../services/app-context';
 
 suite('gemini-message tests', () => {
@@ -260,7 +260,7 @@
       reportStub.firstCall.args[0],
       Interaction.AI_AGENT_SUGGESTIONS_SHOWN
     );
-    const details = reportStub.firstCall.args[1] as AiAgentEventDetails;
+    const details = reportStub.firstCall.args[1] as AiAgentChatEventDetails;
     assert.equal(details.conversationId, 'test-conversation-id');
     assert.equal(details.agentId, 'custom-agent-id');
     assert.equal(details.commentCount, 1);
@@ -308,7 +308,7 @@
       .find(c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_TO_COMMENT);
     assert.isOk(call, 'Expected AI_AGENT_SUGGESTION_TO_COMMENT to be reported');
 
-    const details = call.args[1] as AiAgentEventDetails;
+    const details = call.args[1] as AiAgentChatEventDetails;
     assert.equal(details.conversationId, 'test-conversation-id');
     assert.equal(details.agentId, 'custom-agent-id');
     assert.equal(details.commentId, 'test-comment-id');
@@ -352,7 +352,7 @@
       'Expected AI_AGENT_SUGGESTION_COPY_BUTTON_CLICKED to be reported'
     );
 
-    const details = call.args[1] as AiAgentEventDetails;
+    const details = call.args[1] as AiAgentChatEventDetails;
     assert.equal(details.conversationId, 'test-conversation-id');
     assert.equal(details.agentId, 'custom-agent-id');
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts
index ddf37ab..74af4d8 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts
@@ -75,10 +75,12 @@
     if (runs.length === 1 && runs[0].statusLink) {
       links.push(runs[0].statusLink);
     }
+    const isAi = runs.some(run => run.isAiPowered);
     return html`<gr-checks-chip
       .text=${`${runsCount}`}
       .links=${links}
       .statusOrCategory=${category}
+      .isAi=${isAi}
       @click=${() => {
         fireShowTab(this, Tab.CHECKS, false, {
           checksTab: {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts
index cac448a..641f2f8 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts
@@ -58,4 +58,22 @@
       </div>`
     );
   });
+
+  test('sets isAi to true when check run is AI powered', async () => {
+    element.runs = [
+      createRun({
+        labelName: 'Verified',
+        isAiPowered: true,
+        results: [
+          createCheckResult({
+            category: Category.ERROR,
+          }),
+        ],
+      }),
+    ];
+    await element.updateComplete;
+
+    const checksChip = queryAndAssert<GrChecksChip>(element, 'gr-checks-chip');
+    assert.isTrue(checksChip.isAi);
+  });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index c28df76..71085f2 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -155,6 +155,7 @@
         category: 'ERROR',
         summary: 'Test Summary',
         message: 'Test Message',
+        isAiPowered: true,
         externalId: JSON.stringify({
           agentId: 'test-agent',
           conversationId: 'test-conv',
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 bfdde07..a9284c5 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
@@ -29,7 +29,11 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {assert} from '../../../utils/common-util';
 import {resolve} from '../../../models/dependency';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createApplyFixUrl,
+} from '../../../models/views/change';
+
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
@@ -114,6 +118,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -481,16 +487,22 @@
       });
     }
     if (res?.ok) {
+      const currentChildView = this.getViewModel().getState()?.childView;
+      const filePath =
+        fixSuggestion.replacements[0]?.path ??
+        this.currentPreviews[0]?.filepath;
       this.getNavigation().setUrl(
-        createChangeUrl({
+        createApplyFixUrl({
           change,
-          patchNum: EDIT,
           basePatchNum: patchNum as BasePatchSetNum,
           forceReload: !this.hasEdit,
+          filePath,
+          currentChildView,
         })
       );
       this.close(true);
     }
+
     this.isApplyFixLoading = false;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 1d35430..21ce8ed 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -14,12 +14,15 @@
 import {GrApplyFixDialog} from './gr-apply-fix-dialog';
 import {PatchSetNum, PatchSetNumber} from '../../../types/common';
 import {
+  createChangeViewState,
+  createDiffViewState,
   createFixSuggestionInfo,
   createParsedChange,
   createRange,
   createRevisions,
   getCurrentRevision,
 } from '../../../test/test-data-generators';
+import {changeViewModelToken} from '../../../models/views/change';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -227,4 +230,70 @@
       '/c/test-project/+/42/2..edit?forceReload=true'
     );
   });
+
+  suite('handleApplyFix navigation', () => {
+    setup(() => {
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response(null, {status: 200}))
+      );
+    });
+
+    test('navigates to createDiffUrl when in Diff View', async () => {
+      testResolver(changeViewModelToken).setState(createDiffViewState());
+      const fixDetail: OpenFixPreviewEventDetail = {
+        patchNum: 2 as PatchSetNum,
+        fixSuggestions: [
+          {
+            ...createFixSuggestionInfo('fix_1'),
+            replacements: [
+              {
+                path: 'file1.txt',
+                replacement: 'new content',
+                range: createRange(),
+              },
+            ],
+          },
+        ],
+        onCloseFixPreviewCallbacks: [],
+      };
+      await open(fixDetail);
+
+      await element.handleApplyFix(new CustomEvent('confirm'));
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/2..edit/file1.txt?forceReload=true'
+      );
+    });
+
+    test('navigates to createChangeUrl when in Change View', async () => {
+      testResolver(changeViewModelToken).setState(createChangeViewState());
+      const fixDetail: OpenFixPreviewEventDetail = {
+        patchNum: 2 as PatchSetNum,
+        fixSuggestions: [
+          {
+            ...createFixSuggestionInfo('fix_1'),
+            replacements: [
+              {
+                path: 'file1.txt',
+                replacement: 'new content',
+                range: createRange(),
+              },
+            ],
+          },
+        ],
+        onCloseFixPreviewCallbacks: [],
+      };
+      await open(fixDetail);
+
+      await element.handleApplyFix(new CustomEvent('confirm'));
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/2..edit?forceReload=true'
+      );
+    });
+  });
 });
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 907c764..0a6538b 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
@@ -35,6 +35,7 @@
   PARENT,
   PatchRange,
   PatchSetNum,
+  PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
 } from '../../../types/common';
@@ -71,6 +72,7 @@
   RangeSelectedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
+import {DiffDetails} from '../../../api/annotation';
 import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -287,6 +289,10 @@
   @state()
   private layers: DiffLayer[] = [];
 
+  private layersComputedWithPlugins = false;
+
+  private enableTokenHighlight?: boolean;
+
   @state()
   private renderPrefs: RenderPreferences = {
     num_lines_rendered_at_once: 128,
@@ -376,6 +382,15 @@
         this.prefs = diffPreferences;
       }
     );
+    subscribe(
+      this,
+      () => this.getPluginLoader().pluginsModel.pluginsLoaded$,
+      async pluginsLoaded => {
+        if (pluginsLoaded) {
+          await this.computeLayersWithPlugins();
+        }
+      }
+    );
   }
 
   override connectedCallback() {
@@ -446,6 +461,18 @@
     }
   }
 
+  override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    const pathChanged = changedProperties.has('path');
+    const diffElementChanged =
+      this._layersComputedForDiffElement !== this.diffElement;
+    const pluginsLoaded =
+      this.getPluginLoader().pluginsModel.getState().pluginsLoaded;
+    if ((pathChanged || diffElementChanged) && pluginsLoaded) {
+      this.computeLayersWithPlugins();
+    }
+  }
+
   async waitForReloadToRender(): Promise<void> {
     await this.updateComplete;
     if (this.reloadPromise) {
@@ -523,13 +550,45 @@
     );
   }
 
+  private _layersComputedForPath?: string;
+
+  private _layersComputedForDiffElement?: GrDiff;
+
+  private calculateEnableTokenHighlight(prefs?: PreferencesInfo): boolean {
+    return !prefs?.disable_token_highlighting;
+  }
+
+  private async computeLayersWithPlugins(): Promise<void> {
+    if (
+      this._layersComputedForPath !== this.path ||
+      this._layersComputedForDiffElement !== this.diffElement
+    ) {
+      this.layersComputedWithPlugins = false;
+      this._layersComputedForPath = this.path;
+      this._layersComputedForDiffElement = this.diffElement;
+    }
+
+    if (!this.path || !this.diffElement || this.layersComputedWithPlugins) {
+      return;
+    }
+    if (this.enableTokenHighlight === undefined) {
+      const prefs = await this.restApiService.getPreferences();
+      this.enableTokenHighlight = this.calculateEnableTokenHighlight(prefs);
+    }
+    this.layers = this.getLayers(this.enableTokenHighlight);
+    this.layersComputedWithPlugins = true;
+  }
+
   async initLayers() {
     const preferencesPromise = this.restApiService.getPreferences();
     const prefs = await preferencesPromise;
-    const enableTokenHighlight = !prefs?.disable_token_highlighting;
+    this.enableTokenHighlight = this.calculateEnableTokenHighlight(prefs);
 
     assertIsDefined(this.path, 'path');
-    this.layers = this.getLayers(enableTokenHighlight);
+    this.layers = this.getLayers(this.enableTokenHighlight);
+    this.layersComputedWithPlugins =
+      !!this.diffElement &&
+      this.getPluginLoader().pluginsModel.getState().pluginsLoaded;
     this.coverageRanges = [];
     // We kick off fetching the data here, but we don't return the promise,
     // so awaiting initLayers() will not wait for coverage data to be
@@ -668,6 +727,26 @@
     };
   }
 
+  private get diffDetails(): DiffDetails | undefined {
+    if (
+      !this.change ||
+      !this.patchRange ||
+      !this.file ||
+      !this.path ||
+      !this.diffElement
+    ) {
+      return undefined;
+    }
+    return {
+      change: this.change,
+      basePatchNum: this.patchRange.basePatchNum,
+      patchNum: this.patchRange.patchNum,
+      fileRange: this.file,
+      path: this.path,
+      diffElement: this.diffElement,
+    };
+  }
+
   private getLayers(enableTokenHighlight: boolean): DiffLayer[] {
     const layers = [];
     if (enableTokenHighlight) {
@@ -690,12 +769,29 @@
         })
       );
     }
+    // Add layers from plugins
+    const details = this.diffDetails;
+    if (details) {
+      for (const plugin of this.getPluginLoader().pluginsModel.getState()
+        .diffLayerPlugins) {
+        try {
+          layers.push(plugin.factory(details));
+        } catch (e) {
+          console.error(
+            `Error creating diff layer from plugin ${plugin.pluginName}:`,
+            e
+          );
+        }
+      }
+    }
     layers.push(this.syntaxLayer);
     return layers;
   }
 
   clear() {
     this.layers = [];
+    this.layersComputedWithPlugins = false;
+    this.enableTokenHighlight = undefined;
   }
 
   /**
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 00a627b..958d2fb 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
@@ -1474,4 +1474,75 @@
       });
     });
   });
+
+  suite('computeLayersWithPlugins and token highlighting', () => {
+    let getPreferencesStub: sinon.SinonStub;
+    setup(() => {
+      getPreferencesStub = stubRestApi('getPreferences');
+    });
+    test('initLayers sets enableTokenHighlight correctly', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      await element.initLayers();
+      assert.isFalse(element['enableTokenHighlight']);
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: false})
+      );
+      await element.initLayers();
+      assert.isTrue(element['enableTokenHighlight']);
+      getPreferencesStub.returns(Promise.resolve(undefined));
+      await element.initLayers();
+      assert.isTrue(element['enableTokenHighlight']);
+    });
+    test('clear resets enableTokenHighlight', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      await element.initLayers();
+      assert.isFalse(element['enableTokenHighlight']);
+      element.clear();
+      assert.isUndefined(element['enableTokenHighlight']);
+    });
+    test('computeLayersWithPlugins sets enableTokenHighlight if undefined', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      element['enableTokenHighlight'] = undefined;
+      element['layersComputedWithPlugins'] = false;
+
+      await element['computeLayersWithPlugins']();
+
+      assert.isFalse(element['enableTokenHighlight']);
+      assert.isTrue(element['layersComputedWithPlugins']);
+      assert.isTrue(getPreferencesStub.calledOnce);
+    });
+    test('computeLayersWithPlugins does not fetch preferences if enableTokenHighlight is already set', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      element['enableTokenHighlight'] = true;
+      element['layersComputedWithPlugins'] = false;
+
+      await element['computeLayersWithPlugins']();
+
+      assert.isTrue(element['enableTokenHighlight']);
+      assert.isTrue(element['layersComputedWithPlugins']);
+      assert.isFalse(getPreferencesStub.called);
+    });
+    test('pluginsLoaded triggers computeLayersWithPlugins', async () => {
+      // @ts-expect-error
+      const computeSpy = sinon.spy(element, 'computeLayersWithPlugins');
+
+      const pluginsModel = testResolver(pluginLoaderToken).pluginsModel;
+      pluginsModel.updateState({pluginsLoaded: false});
+      await element.updateComplete;
+
+      pluginsModel.updateState({pluginsLoaded: true});
+
+      await new Promise(resolve => setTimeout(resolve, 0));
+
+      assert.isTrue(computeSpy.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 93bf666..cf151d4 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
@@ -19,6 +19,8 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import '../../change/gr-download-dialog/gr-download-dialog';
+import '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
 import {
@@ -88,7 +90,7 @@
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
-import {styleMap} from 'lit/directives/style-map.js';
+import {keyed} from 'lit/directives/keyed.js';
 import {
   ChangeChildView,
   changeViewModelToken,
@@ -127,6 +129,8 @@
 
 @customElement('gr-diff-view')
 export class GrDiffView extends LitElement {
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   /**
    * Fired when user tries to navigate away while comments are pending save.
    *
@@ -153,11 +157,6 @@
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  @query('.sidebarAnchor')
-  sidebarAnchor?: HTMLDivElement;
-
-  @state() private sidebarHeight = 0;
-
   // Private but used in tests.
   @state()
   get patchRange(): PatchRange | undefined {
@@ -526,7 +525,6 @@
         :host {
           display: block;
           background-color: var(--view-background-color);
-          --sidebar-width: 300px;
         }
         .hidden {
           display: none;
@@ -702,24 +700,14 @@
         :host(.hideCheckCodePointers) {
           --gr-check-code-pointers-display: none;
         }
-        .diffContainer.sidebarOpen {
-          margin-left: var(--sidebar-width);
-        }
         .sidebarTriggerContainer {
           display: inline-block;
           margin-right: var(--spacing-m);
         }
-        .sidebarAnchor {
-          height: 0;
-          width: 0;
-          overflow: visible;
-        }
         .sidebarContents {
           background: var(--background-color-secondary);
-          width: var(--sidebar-width);
-          border: var(--spacing-xxs) solid var(--border-color);
-          border-left: 0;
-          overflow: auto;
+          box-sizing: border-box;
+          height: 100%;
         }
         md-checkbox {
           --md-checkbox-container-size: 15px;
@@ -749,8 +737,6 @@
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
     this.cursor = new GrDiffCursor();
     if (this.diffHost) this.reInitCursor();
-    window.addEventListener('scroll', this.updateSidebarHeight);
-    window.addEventListener('resize', this.updateSidebarHeight);
     this.getUserModel()
       .preferences$.pipe(
         map(p => p.diff_page_sidebar),
@@ -768,8 +754,6 @@
 
   override disconnectedCallback() {
     this.cursor?.dispose();
-    window.removeEventListener('scroll', this.updateSidebarHeight);
-    window.removeEventListener('resize', this.updateSidebarHeight);
     super.disconnectedCallback();
   }
 
@@ -779,13 +763,6 @@
     this.cursor?.reInitCursor();
   }
 
-  private readonly updateSidebarHeight = () => {
-    if (this.sidebarAnchor) {
-      this.sidebarHeight =
-        window.innerHeight - this.sidebarAnchor.getBoundingClientRect().bottom;
-    }
-  };
-
   protected override updated(changedProperties: PropertyValues): void {
     super.updated(changedProperties);
     if (
@@ -844,7 +821,6 @@
         this.patchRange
       );
     }
-    this.updateSidebarHeight();
   }
 
   override render() {
@@ -868,34 +844,41 @@
           </div>
         `
       )}
-      <div
-        class=${classMap({
-          diffContainer: true,
-          sidebarOpen: !!this.shownSidebar,
-          hidden: !!this.file?.diffs_too_expensive_to_compute,
-        })}
+      <gr-content-with-sidebar
+        .side=${this.getSidebarSide()}
+        .hideSide=${!this.shownSidebar}
       >
-        <gr-endpoint-decorator name="diff-content">
-          <gr-diff-host
-            id="diffHost"
-            .changeNum=${this.changeNum}
-            .change=${this.change}
-            .patchRange=${this.patchRange}
-            .file=${file}
-            .lineOfInterest=${this.getLineOfInterest()}
-            .path=${this.path}
-            .projectName=${this.change?.project}
-            @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
-            @comment-anchor-tap=${this.onCommentAnchorTap}
-            @line-selected=${this.onLineSelected}
-            @diff-changed=${this.onDiffChanged}
-            @edit-weblinks-changed=${this.onEditWeblinksChanged}
-            @files-weblinks-changed=${this.onFilesWeblinksChanged}
-            @render=${this.reInitCursor}
-          >
-          </gr-diff-host>
-        </gr-endpoint-decorator>
-      </div>
+        <div
+          slot="main"
+          class=${classMap({
+            diffContainer: true,
+            sidebarOpen: !!this.shownSidebar,
+            hidden: !!this.file?.diffs_too_expensive_to_compute,
+          })}
+        >
+          <gr-endpoint-decorator name="diff-content">
+            <gr-diff-host
+              id="diffHost"
+              .changeNum=${this.changeNum}
+              .change=${this.change}
+              .patchRange=${this.patchRange}
+              .file=${file}
+              .lineOfInterest=${this.getLineOfInterest()}
+              .path=${this.path}
+              .projectName=${this.change?.project}
+              @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
+              @comment-anchor-tap=${this.onCommentAnchorTap}
+              @line-selected=${this.onLineSelected}
+              @diff-changed=${this.onDiffChanged}
+              @edit-weblinks-changed=${this.onEditWeblinksChanged}
+              @files-weblinks-changed=${this.onFilesWeblinksChanged}
+              @render=${this.reInitCursor}
+            >
+            </gr-diff-host>
+          </gr-endpoint-decorator>
+        </div>
+        <div slot="side">${this.renderSidebarContent()}</div>
+      </gr-content-with-sidebar>
       ${this.renderDialogs()}
     `;
   }
@@ -920,7 +903,6 @@
           >&gt;</a
         >
       </div>
-      ${this.renderSidebarContent()}
     </div>`;
   }
 
@@ -1023,17 +1005,33 @@
     `;
   }
 
+  private getSidebarSide(): 'left' | 'right' {
+    if (!this.shownSidebar) {
+      return 'left';
+    }
+    const details = this.getPluginLoader().pluginEndPoints.getDetails(
+      `sidebarContent-${this.shownSidebar}`
+    );
+    for (const info of details) {
+      if (info.moduleName) {
+        const customElement = customElements.get(info.moduleName) as
+          | (CustomElementConstructor & {sidebarPosition?: string})
+          | undefined;
+        if (customElement?.sidebarPosition === 'right') {
+          return 'right';
+        }
+      }
+    }
+    return 'left';
+  }
+
   private renderSidebarContent() {
-    // Always renders the 0x0px .sidebarAnchor div for scroll measurements.
     return html`
-      <div class="sidebarAnchor">
-        ${when(
-          this.shownSidebar !== undefined,
-          () => html`
-            <div
-              class="sidebarContents"
-              style=${styleMap({height: `${this.sidebarHeight}px`})}
-            >
+      ${when(this.shownSidebar !== undefined, () =>
+        keyed(
+          this.shownSidebar,
+          html`
+            <div class="sidebarContents">
               <gr-endpoint-decorator
                 name=${`sidebarContent-${this.shownSidebar}`}
               >
@@ -1080,7 +1078,9 @@
                     // Only close the sidebar if that particular sidebar is
                     // still open. An async onClose callback should not close a
                     // different sidebar.
-                    if (this.shownSidebar !== pluginName) return;
+                    if (this.shownSidebar !== pluginName) {
+                      return;
+                    }
                     this.shownSidebar = undefined;
                     this.getUserModel().updatePreferences({
                       diff_page_sidebar: 'NONE',
@@ -1091,8 +1091,8 @@
               </gr-endpoint-decorator>
             </div>
           `
-        )}
-      </div>
+        )
+      )}
     `;
   }
 
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 b66542e..daed454 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
@@ -51,7 +51,9 @@
 } from '../../../types/common';
 import {CursorMoveResult} from '../../../api/core';
 import {Side} from '../../../api/diff';
+import {PluginApi} from '../../../api/plugin';
 import {Files, GrDiffView} from './gr-diff-view';
+import {GrContentWithSidebar} from '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {SinonFakeTimers, SinonStub, SinonStubbedMember} from 'sinon';
 import {
@@ -398,14 +400,16 @@
                 >
               </a>
             </div>
-            <div class="sidebarAnchor"></div>
           </div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <div class="diffContainer">
-            <gr-endpoint-decorator name="diff-content">
-              <gr-diff-host id="diffHost"> </gr-diff-host>
-            </gr-endpoint-decorator>
-          </div>
+          <gr-content-with-sidebar>
+            <div class="diffContainer" slot="main">
+              <gr-endpoint-decorator name="diff-content">
+                <gr-diff-host id="diffHost"> </gr-diff-host>
+              </gr-endpoint-decorator>
+            </div>
+            <div slot="side"></div>
+          </gr-content-with-sidebar>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
@@ -2261,5 +2265,76 @@
         'Button should be hidden for image diffs'
       );
     });
+
+    suite('sidebar', () => {
+      test('switching sidebars dismounts old component and mounts new one', async () => {
+        // @ts-expect-error: accessing private property shownSidebar for testing
+        element.shownSidebar = 'sidebar-a';
+        await element.updateComplete;
+        const oldDecorator = element.shadowRoot?.querySelector(
+          '.sidebarContents gr-endpoint-decorator'
+        );
+        assert.isNotNull(oldDecorator);
+        assert.equal(
+          oldDecorator?.getAttribute('name'),
+          'sidebarContent-sidebar-a'
+        );
+
+        // @ts-expect-error: accessing private property shownSidebar for testing
+        element.shownSidebar = 'sidebar-b';
+        await element.updateComplete;
+        const newDecorator = element.shadowRoot?.querySelector(
+          '.sidebarContents gr-endpoint-decorator'
+        );
+        assert.isNotNull(newDecorator);
+        assert.equal(
+          newDecorator?.getAttribute('name'),
+          'sidebarContent-sidebar-b'
+        );
+        assert.notEqual(oldDecorator, newDecorator);
+      });
+
+      test('defaults to left sidebar side when no sidebarPosition property set', async () => {
+        // @ts-expect-error: accessing private property shownSidebar for testing
+        element.shownSidebar = 'left-sidebar';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.isNotNull(contentWithSidebar);
+        assert.equal(contentWithSidebar?.side, 'left');
+      });
+
+      test('detects right sidebar side when static sidebarPosition === "right"', async () => {
+        class RightSidebar extends HTMLElement {
+          static sidebarPosition = 'right';
+        }
+
+        customElements.define('right-sidebar-element', RightSidebar);
+
+        let plugin!: PluginApi;
+        window.Gerrit.install(
+          p => (plugin = p),
+          '0.1',
+          'http://test.com/plugins/testplugin/static/test.js'
+        );
+        plugin.registerDynamicCustomComponent(
+          'sidebarContent',
+          'right-sidebar-element'
+        );
+
+        // @ts-expect-error: accessing private property shownSidebar for testing
+        element.shownSidebar = 'testplugin';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.equal(contentWithSidebar?.side, 'right');
+      });
+    });
   });
 });
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 7184e00..778a17c 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
@@ -6,6 +6,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-default-editor/gr-default-editor';
@@ -28,6 +29,7 @@
 import {changeIsAbandoned, changeIsMerged} from '../../../utils/change-util';
 import {Modifier} from '../../../utils/dom-util';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, query, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
@@ -66,6 +68,9 @@
   @query('#editPreferencesDialog')
   editPreferencesDialog?: GrEditPreferencesDialog;
 
+  @query('#confirmCloseModal')
+  private confirmCloseModal?: HTMLDialogElement;
+
   @state() viewState?: ChangeViewState;
 
   // private but used in test
@@ -171,6 +176,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         :host {
           background-color: var(--view-background-color);
@@ -291,6 +297,18 @@
         @has-edit-pref-change-saved=${this.handleEditPrefChangeSaved}
       >
       </gr-edit-preferences-dialog>
+      <dialog id="confirmCloseModal" tabindex="-1">
+        <gr-dialog
+          id="confirmCloseDialog"
+          confirm-label="Discard"
+          cancel-label="Keep Editing"
+          @confirm=${this.handleConfirmClose}
+          @cancel=${this.handleCancelClose}
+        >
+          <div class="header" slot="header">Discard unsaved changes?</div>
+          <div class="main" slot="main">Your modifications will be lost.</div>
+        </gr-dialog>
+      </dialog>
     `;
   }
 
@@ -507,8 +525,11 @@
 
   // private but used in test
   handleCloseTap = () => {
-    // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
-    this.viewEditInChangeView();
+    if ((this.content ?? '') !== this.newContent) {
+      this.confirmCloseModal?.showModal();
+    } else {
+      this.viewEditInChangeView();
+    }
   };
 
   private handleSaveTap = () => {
@@ -610,6 +631,16 @@
     // We have to fire a reload so the change takes effect within a plugin.
     fireReload(this);
   }
+
+  private handleConfirmClose = () => {
+    this.getStorage().eraseEditableContentItem(this.storageKey);
+    this.confirmCloseModal?.close();
+    this.viewEditInChangeView();
+  };
+
+  private handleCancelClose = () => {
+    this.confirmCloseModal?.close();
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_screenshot_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_screenshot_test.ts
new file mode 100644
index 0000000..ec1bd6a
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_screenshot_test.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-editor-view';
+import {GrEditorView} from './gr-editor-view';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {
+  query,
+  stubRestApi,
+  visualDiffDarkTheme,
+} from '../../../test/test-utils';
+import {createEditViewState} from '../../../test/test-data-generators';
+import {NumericChangeId, RevisionPatchSetNum} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+suite('gr-editor-view screenshot tests', () => {
+  let element: GrEditorView;
+
+  setup(async () => {
+    stubRestApi('getFileContent').resolves({
+      ok: true,
+      type: 'text/javascript',
+      content: 'original content',
+    });
+    element = await fixture<GrEditorView>(
+      html`<gr-editor-view></gr-editor-view>`
+    );
+    element.viewState = {
+      ...createEditViewState(),
+      changeNum: 42 as NumericChangeId,
+      patchNum: 1 as RevisionPatchSetNum,
+      editView: {path: 'foo/bar.baz'},
+    };
+    element.latestPatchsetNumber = 1 as RevisionPatchSetNum;
+    element.content = 'original content';
+    element.newContent = 'original content';
+    await element.updateComplete;
+  });
+
+  test('editor view', async () => {
+    await visualDiff(element, 'gr-editor-view-normal');
+    await visualDiffDarkTheme(element, 'gr-editor-view-normal');
+  });
+
+  test('cancel modal open', async () => {
+    element.newContent = 'modified content';
+    await element.updateComplete;
+
+    query<GrButton>(element, '#close')!.click();
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-editor-view-cancel-modal');
+    await visualDiffDarkTheme(element, 'gr-editor-view-cancel-modal');
+  });
+});
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 690180c..090c521 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -127,6 +127,17 @@
         </div>
         <gr-edit-preferences-dialog id="editPreferencesDialog">
         </gr-edit-preferences-dialog>
+        <dialog id="confirmCloseModal" tabindex="-1">
+          <gr-dialog
+            cancel-label="Keep Editing"
+            confirm-label="Discard"
+            id="confirmCloseDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Discard unsaved changes?</div>
+            <div class="main" slot="main">Your modifications will be lost.</div>
+          </gr-dialog>
+        </dialog>
         <div class="textareaWrapper">
           <gr-endpoint-decorator id="editorEndpoint" name="editor">
             <gr-endpoint-param name="fileContent"> </gr-endpoint-param>
@@ -216,6 +227,11 @@
     const newText = 'file text changed';
 
     setup(async () => {
+      stubRestApi('getFileContent').resolves({
+        ok: true,
+        type: 'text/javascript',
+        content: originalText,
+      });
       element.viewState = {...createEditViewState()};
       element.content = originalText;
       element.newContent = originalText;
@@ -351,17 +367,89 @@
       });
     });
 
-    test('file modification and close', async () => {
-      const closeSpy = sinon.spy(element, 'handleCloseTap');
+    test('file modification and close show dialog, then discard', async () => {
+      storageService.setEditableContentItem(
+        element.storageKey,
+        'cached content'
+      );
+      assert.equal(
+        storageService.getEditableContentItem(element.storageKey)?.message,
+        'cached content'
+      );
+
       element.newContent = newText;
       await element.updateComplete;
 
-      assert.isFalse(
-        query<GrButton>(element, '#save')!.hasAttribute('disabled')
-      );
+      const dialog = query<HTMLDialogElement>(element, '#confirmCloseModal')!;
+      assert.isFalse(dialog.hasAttribute('open'));
 
       query<GrButton>(element, '#close')!.click();
-      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isFalse(navigateStub.called);
+      assert.isTrue(dialog.hasAttribute('open'));
+
+      query(element, '#confirmCloseDialog')!.dispatchEvent(
+        new CustomEvent('confirm')
+      );
+      await element.updateComplete;
+
+      assert.isFalse(dialog.hasAttribute('open'));
+      assert.isTrue(navigateStub.called);
+      assert.isNull(storageService.getEditableContentItem(element.storageKey));
+    });
+
+    test('file modification and close show dialog, then keep editing', async () => {
+      element.newContent = newText;
+      await element.updateComplete;
+
+      const dialog = query<HTMLDialogElement>(element, '#confirmCloseModal')!;
+      assert.isFalse(dialog.hasAttribute('open'));
+
+      query<GrButton>(element, '#close')!.click();
+      assert.isFalse(saveFileStub.called);
+      assert.isFalse(navigateStub.called);
+      assert.isTrue(dialog.hasAttribute('open'));
+
+      query(element, '#confirmCloseDialog')!.dispatchEvent(
+        new CustomEvent('cancel')
+      );
+      await element.updateComplete;
+
+      assert.isFalse(dialog.hasAttribute('open'));
+      assert.isFalse(navigateStub.called);
+    });
+
+    test('render open dialog', async () => {
+      element.newContent = newText;
+      await element.updateComplete;
+
+      query<GrButton>(element, '#close')!.click();
+      await element.updateComplete;
+
+      const dialog = query<HTMLDialogElement>(element, '#confirmCloseModal')!;
+      assert.isTrue(dialog.hasAttribute('open'));
+
+      assert.lightDom.equal(
+        dialog,
+        /* HTML */ `
+          <gr-dialog
+            cancel-label="Keep Editing"
+            confirm-label="Discard"
+            id="confirmCloseDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Discard unsaved changes?</div>
+            <div class="main" slot="main">Your modifications will be lost.</div>
+          </gr-dialog>
+        `
+      );
+    });
+
+    test('close when content is not modified', async () => {
+      element.newContent = originalText;
+      await element.updateComplete;
+
+      query<GrButton>(element, '#close')!.click();
       assert.isFalse(saveFileStub.called);
       assert.isTrue(navigateStub.called);
     });
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 53811e9..3020bff 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
@@ -355,6 +355,7 @@
       sharedStyles,
       css`
         :host {
+          display: block;
           font-family: var(--font-family);
           font-size: var(--font-size-normal);
           font-weight: var(--font-weight-normal);
@@ -722,10 +723,19 @@
   }
 
   override firstUpdated() {
+    const lastComment = this.thread ? this.getLastComment() : undefined;
+    const isNewDraft =
+      isDraft(lastComment) && (lastComment?.message ?? '') === '';
     if (this.shouldScrollIntoView) {
       whenRendered(this, () => {
         this.expandCollapseComments(false);
-        this.commentBox?.focus();
+        // Because of the non-deterministic order of focus events firing from
+        // the JS event loop, focusing the comment box on a new draft can result
+        // in the draft comment not being focused, which means the user has to
+        // click into it to start typing.
+        if (!isNewDraft) {
+          this.commentBox?.focus();
+        }
         // The delay is a hack because we don't know exactly when to
         // scroll the comment into center.
         // TODO: Find a better solution without a setTimeout
@@ -735,9 +745,9 @@
         }, 500);
       });
     }
-    if (this.thread && isDraft(this.getFirstComment())) {
-      const msg = this.getFirstComment()?.message ?? '';
-      if (msg.length === 0) this.editDraft();
+    // Focus the draft comment input to avoid the user having to click into it.
+    if (isNewDraft) {
+      this.editDraft();
     }
   }
 
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 816054b..8b5e166 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
@@ -173,6 +173,36 @@
     );
   });
 
+  test('focuses commentBox when it is NOT a new draft', async () => {
+    const thread = createThread(c1);
+    const element = await fixture<GrCommentThread>(html`
+      <gr-comment-thread
+        .thread=${thread}
+        .shouldScrollIntoView=${true}
+      ></gr-comment-thread>
+    `);
+    await element.updateComplete;
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    const commentBox = queryAndAssert<HTMLElement>(element, '.comment-box');
+    assert.equal(element.shadowRoot?.activeElement, commentBox);
+  });
+
+  test('does not focus commentBox when it IS a new draft', async () => {
+    const thread = createThread(createNewDraft({message: ''}));
+    const element = await fixture<GrCommentThread>(html`
+      <gr-comment-thread
+        .thread=${thread}
+        .shouldScrollIntoView=${true}
+      ></gr-comment-thread>
+    `);
+    await element.updateComplete;
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    const commentBox = queryAndAssert<HTMLElement>(element, '.comment-box');
+    assert.notEqual(element.shadowRoot?.activeElement, commentBox);
+  });
+
   test('comment box spans 100% of container width', async () => {
     const wrapper = await fixture(html`
       <div style="width: 500px;">
@@ -518,6 +548,85 @@
       // The draft should be discarded completely
       assert.equal(draftElement.messageText, '');
     });
+
+    test('handle Quote with multi-line message', async () => {
+      stubAdd.restore();
+      stubAdd = sinon
+        .stub(testResolver(commentsModelToken), 'addNewDraft')
+        .callsFake(draft => {
+          const newDraft = {
+            ...draft,
+            id: 'new-draft' as UrlEncodedCommentId,
+            __draft: true,
+          };
+          if (element.thread) {
+            element.thread = {
+              ...element.thread,
+              comments: [...element.thread.comments, newDraft],
+            };
+          }
+          return Promise.resolve(newDraft);
+        });
+
+      element.thread = createThread(c1, {
+        ...c2,
+        message: 'first line\nsecond line\nthird line',
+        unresolved: true,
+      });
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#quoteBtn').click();
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.in_reply_to, c2.id);
+      await element.updateComplete;
+
+      const draftElement = queryAndAssert<GrComment>(
+        element,
+        'gr-comment.draft'
+      );
+      await draftElement.updateComplete;
+      await waitUntil(
+        () =>
+          draftElement.messageText ===
+          '> first line\n> second line\n> third line\n\n'
+      );
+      assert.equal(
+        draftElement.messageText,
+        '> first line\n> second line\n> third line\n\n'
+      );
+    });
+
+    test('handle reply-to-comment event from child comment', async () => {
+      element.thread = createThread(c1, {...c2, unresolved: true});
+      await element.updateComplete;
+
+      const commentEl = queryAndAssert<GrComment>(element, 'gr-comment');
+      commentEl.dispatchEvent(
+        new CustomEvent('reply-to-comment', {
+          detail: {
+            content: 'custom response',
+            userWantsToEdit: true,
+            unresolved: true,
+          },
+          bubbles: true,
+          composed: true,
+        })
+      );
+
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.in_reply_to, c2.id);
+      assert.equal(stubAdd.lastCall.firstArg.unresolved, true);
+    });
+
+    test('reply sets in_reply_to to the last comment id in thread', async () => {
+      element.thread = createThread(c1, c2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#replyBtn').click();
+      assert.isTrue(stubAdd.called);
+      const newDraft = stubAdd.lastCall.firstArg;
+      assert.equal(newDraft.in_reply_to, c2.id);
+    });
   });
 
   test('comments are sorted correctly', () => {
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 418ddb0..24ca939 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1381,11 +1381,9 @@
   override updated(changed: PropertyValues) {
     if (changed.has('editing')) {
       if (this.editing && !this.permanentEditingMode) {
-        // Note that this is a bit fragile, because we are relying on the
-        // comment to become visible soonish. If that does not happen, then we
-        // will be waiting indefinitely and grab focus at some point in the
-        // distant future.
-        whenVisible(this, () => this.textarea?.putCursorAtEnd());
+        this.focusTextarea().catch(() => {
+          // Ignore error since failure to focus is non-fatal.
+        });
       }
     }
     if (changed.has('changeNum') || changed.has('comment')) {
@@ -1400,6 +1398,27 @@
     }
   }
 
+  private async focusTextarea(): Promise<void> {
+    await this.updateComplete;
+    if (!this.textarea) {
+      return;
+    }
+    await this.textarea.updateComplete;
+    if (this.isVisible()) {
+      this.textarea.putCursorAtEnd();
+    } else {
+      // Note that this is a bit fragile, because we are relying on the
+      // comment to become visible soonish. If that does not happen, then we
+      // will be waiting indefinitely and grab focus at some point in the
+      // distant future.
+      whenVisible(this, () => this.textarea?.putCursorAtEnd());
+    }
+  }
+
+  private isVisible(): boolean {
+    return this.offsetWidth > 0 || this.offsetHeight > 0;
+  }
+
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
     if (changed.has('comment')) {
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 58a64bd..b437a74 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
@@ -54,6 +54,7 @@
 import {suggestionsServiceToken} from '../../../services/suggestions/suggestions-service';
 import {AutocompletionContext} from '../../../utils/autocomplete-cache';
 import {FixSuggestionInfo} from '../../../api/rest-api';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
@@ -533,6 +534,24 @@
       assert.isTrue(element.isSaveDisabled());
     });
 
+    test('focuses textarea when editing is set to true', async () => {
+      const spy = sinon.spy(GrSuggestionTextarea.prototype, 'putCursorAtEnd');
+      try {
+        element.comment = createDraft();
+        element.editing = false;
+        await element.updateComplete;
+
+        element.editing = true;
+        await element.updateComplete;
+        // focusTextarea is async, wait for it to complete.
+        await new Promise(resolve => setTimeout(resolve, 0));
+
+        assert.isTrue(spy.called);
+      } finally {
+        spy.restore();
+      }
+    });
+
     test('ctrl+s saves comment', async () => {
       const spy = sinon.stub(element, 'save');
       element.messageText = 'is that the horse from horsing around??';
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
index 0e0638e..869f9df 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
@@ -4,14 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {styleMap} from 'lit/directives/style-map.js';
 
-const SIDEBAR_MIN_WIDTH = 400;
+const SIDEBAR_MIN_WIDTH = 250;
 
 /**
  * A component that displays content in a main area and a resizable sidebar.
- * The sidebar can be toggled between hidden and visible.
+ * The sidebar can be toggled between hidden and visible and positioned on the left or right.
  *
  * slot main - The content to be displayed in the main area.
  * slot side - The content to be displayed in the sidebar.
@@ -21,11 +21,17 @@
   @query('.sidebar-wrapper') sidebarWrapper?: HTMLElement;
 
   @state()
-  private sidebarWidthPx = SIDEBAR_MIN_WIDTH;
+  private sidebarWidthPx = 400;
 
   @property()
   hideSide = true;
 
+  @property()
+  side: 'left' | 'right' = 'right';
+
+  @property({type: Number})
+  minWidth = SIDEBAR_MIN_WIDTH;
+
   private isSidebarResizing = false;
 
   private sidebarResizingStartPosPx = 0;
@@ -43,22 +49,29 @@
         :host {
           display: block;
           position: relative;
-          --sidebar-height: calc(100vh - var(--sidebar-top));
+          --sidebar-height: calc(100vh - var(--sidebar-top, 0px));
         }
         .sidebar-wrapper {
           z-index: 50;
           position: absolute;
           display: flex;
           top: 0;
-          bottom: calc(0px - var(--sidebar-bottom-overflow));
-          right: 0;
-          min-width: 400px;
+          bottom: calc(0px - var(--sidebar-bottom-overflow, 0px));
+          min-width: 250px;
           max-width: 100%;
           background-color: var(--background-color-secondary);
         }
+        .sidebar-wrapper.right {
+          right: 0;
+          left: auto;
+        }
+        .sidebar-wrapper.left {
+          left: 0;
+          right: auto;
+        }
         .sidebar {
           position: sticky;
-          top: var(--sidebar-top);
+          top: var(--sidebar-top, 0px);
           height: var(--sidebar-height);
           box-sizing: border-box;
           overflow: auto;
@@ -67,35 +80,55 @@
         }
         .resizer-wrapper {
           position: sticky;
-          top: var(--sidebar-top);
+          top: var(--sidebar-top, 0px);
           height: var(--sidebar-height);
           z-index: 51;
         }
         .resizer {
           background-color: var(--background-color-secondary);
           width: 7px;
-          border-left: 1px solid var(--border-color);
           cursor: ew-resize;
           position: absolute;
           top: 0;
           bottom: 0;
-          left: -7px;
           box-sizing: border-box;
         }
-        .resizer:hover {
+        .resizer.right-side {
+          left: -7px;
+          border-left: 1px solid var(--border-color);
+        }
+        .resizer.right-side:hover {
           background-color: var(--background-color-tertiary);
           width: 11px;
           left: -9px;
         }
+        .resizer.left-side {
+          right: -7px;
+          border-right: 1px solid var(--border-color);
+        }
+        .resizer.left-side:hover {
+          background-color: var(--background-color-tertiary);
+          width: 11px;
+          right: -9px;
+        }
       `,
     ];
   }
 
   override render() {
     const widthPx = this.hideSide ? 0 : this.sidebarWidthPx;
+    const mainStyle =
+      this.side === 'left'
+        ? styleMap({
+            marginLeft: `${widthPx}px`,
+            width: `calc(100% - ${widthPx}px)`,
+          })
+        : styleMap({
+            width: `calc(100% - ${widthPx}px)`,
+          });
     return html`
       <div>
-        <div style=${styleMap({width: `calc(100% - ${widthPx}px)`})}>
+        <div style=${mainStyle}>
           <slot name="main"></slot>
         </div>
         ${this.renderSidebar()}
@@ -105,25 +138,34 @@
 
   private renderSidebar() {
     if (this.hideSide) return;
+    const sideClass = this.side === 'left' ? 'left' : 'right';
+    const resizerClass = this.side === 'left' ? 'left-side' : 'right-side';
     return html`
       <div
-        class="sidebar-wrapper"
+        class="sidebar-wrapper ${sideClass}"
         style=${styleMap({width: `${this.sidebarWidthPx}px`})}
       >
-        <div class="resizer-wrapper">
-          <div
-            class="resizer"
-            role="separator"
-            aria-orientation="vertical"
-            aria-valuenow=${this.sidebarWidthPx}
-            aria-label="Resize sidebar"
-            tabindex="0"
-            @mousedown=${this.startSidebarResize}
-          ></div>
-        </div>
+        ${this.side === 'right' ? this.renderResizer(resizerClass) : nothing}
         <div class="sidebar">
           <slot name="side"></slot>
         </div>
+        ${this.side === 'left' ? this.renderResizer(resizerClass) : nothing}
+      </div>
+    `;
+  }
+
+  private renderResizer(resizerClass: string) {
+    return html`
+      <div class="resizer-wrapper">
+        <div
+          class="resizer ${resizerClass}"
+          role="separator"
+          aria-orientation="vertical"
+          aria-valuenow=${this.sidebarWidthPx}
+          aria-label="Resize sidebar"
+          tabindex="0"
+          @mousedown=${this.startSidebarResize}
+        ></div>
       </div>
     `;
   }
@@ -157,10 +199,11 @@
     if (!this.isSidebarResizing || event.buttons === 0) return;
 
     const widthDiffPx = event.clientX - this.sidebarResizingStartPosPx;
-    this.sidebarWidthPx = Math.max(
-      this.sidebarResizingStartWidthPx - widthDiffPx,
-      SIDEBAR_MIN_WIDTH
-    );
+    const rawWidth =
+      this.side === 'right'
+        ? this.sidebarResizingStartWidthPx - widthDiffPx
+        : this.sidebarResizingStartWidthPx + widthDiffPx;
+    this.sidebarWidthPx = Math.max(rawWidth, this.minWidth);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
index 52ea31d..7ed19b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
@@ -44,8 +44,23 @@
     await element.updateComplete;
   });
 
-  test('screenshot', async () => {
-    await visualDiff(wrapper, 'gr-content-with-sidebar');
-    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar');
+  test('screenshot right sidebar', async () => {
+    const element = wrapper.querySelector<GrContentWithSidebar>(
+      'gr-content-with-sidebar'
+    )!;
+    element.side = 'right';
+    await element.updateComplete;
+    await visualDiff(wrapper, 'gr-content-with-sidebar-right');
+    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar-right');
+  });
+
+  test('screenshot left sidebar', async () => {
+    const element = wrapper.querySelector<GrContentWithSidebar>(
+      'gr-content-with-sidebar'
+    )!;
+    element.side = 'left';
+    await element.updateComplete;
+    await visualDiff(wrapper, 'gr-content-with-sidebar-left');
+    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar-left');
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
index 7905c0a..626e3dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
@@ -34,8 +34,9 @@
     );
   });
 
-  test('renders sidebar', async () => {
+  test('renders right sidebar', async () => {
     element.hideSide = false;
+    element.side = 'right';
     await element.updateComplete;
 
     assert.shadowDom.equal(
@@ -45,13 +46,13 @@
           <div style="width: calc(100% - 400px);">
             <slot name="main"> </slot>
           </div>
-          <div class="sidebar-wrapper" style="width:400px;">
+          <div class="right sidebar-wrapper" style="width:400px;">
             <div class="resizer-wrapper">
               <div
                 aria-label="Resize sidebar"
                 aria-orientation="vertical"
                 aria-valuenow="400"
-                class="resizer"
+                class="right-side resizer"
                 role="separator"
                 tabindex="0"
               ></div>
@@ -64,4 +65,36 @@
       `
     );
   });
+
+  test('renders left sidebar', async () => {
+    element.hideSide = false;
+    element.side = 'left';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <div style="width: calc(100% - 400px); margin-left: 400px;">
+            <slot name="main"> </slot>
+          </div>
+          <div class="left sidebar-wrapper" style="width:400px;">
+            <div class="sidebar">
+              <slot name="side"> </slot>
+            </div>
+            <div class="resizer-wrapper">
+              <div
+                aria-label="Resize sidebar"
+                aria-orientation="vertical"
+                aria-valuenow="400"
+                class="left-side resizer"
+                role="separator"
+                tabindex="0"
+              ></div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
 });
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 0ee4aa3..bfc6462 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
@@ -52,6 +52,9 @@
   @property({type: Boolean, reflect: true})
   hideInput = false;
 
+  @property({type: Boolean, attribute: 'disable-auto-select', reflect: true})
+  disableAutoSelect = false;
+
   @property({type: String})
   label?: string;
 
@@ -216,6 +219,7 @@
   }
 
   private handleInputClick(e: MouseEvent) {
+    if (this.disableAutoSelect) return;
     e.preventDefault();
     const rootTarget = e.composedPath()[0];
     (rootTarget as HTMLInputElement).select();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index b50ecfd..acc1180 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -128,6 +128,22 @@
     assert.equal(mdOutlinedTextField.selectionEnd, element.text!.length - 1);
   });
 
+  test('handleInputClick with disableAutoSelect', async () => {
+    element.disableAutoSelect = true;
+    await element.updateComplete;
+
+    const mdOutlinedTextField = queryAndAssert<MdOutlinedTextField>(
+      element,
+      'md-outlined-text-field'
+    );
+    mdOutlinedTextField.selectionStart = 0;
+    mdOutlinedTextField.selectionEnd = 0;
+
+    mdOutlinedTextField.click();
+    assert.equal(mdOutlinedTextField.selectionStart, 0);
+    assert.equal(mdOutlinedTextField.selectionEnd, 0);
+  });
+
   test('hideInput', async () => {
     const mdOutlinedTextField = queryAndAssert<MdOutlinedTextField>(
       element,
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 9a7cabc..30defc5 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -54,6 +54,9 @@
   @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
+  @property({type: Boolean, attribute: 'disable-auto-select', reflect: true})
+  disableAutoSelect = false;
+
   // Private but used in tests.
   readonly getUserModel = resolve(this, userModelToken);
 
@@ -171,6 +174,7 @@
         .label=${command.title}
         .command=${command.command}
         .tooltip=${this.computeTooltip(index)}
+        .disableAutoSelect=${this.disableAutoSelect}
       ></gr-shell-command>
     `;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 05011f8f..3f3b4bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -97,6 +97,16 @@
       assert.isTrue(focusStub.called);
     });
 
+    test('passes disableAutoSelect to gr-shell-command', async () => {
+      element.disableAutoSelect = true;
+      await element.updateComplete;
+      const shellCommand = queryAndAssert<GrShellCommand>(
+        element,
+        'gr-shell-command'
+      );
+      assert.isTrue(shellCommand.disableAutoSelect);
+    });
+
     test('element visibility', async () => {
       assert.isFalse(isHidden(queryAndAssert(element, 'md-tabs')));
       assert.isFalse(isHidden(queryAndAssert(element, '.commands')));
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 6c2661f..1261e29 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -337,7 +337,6 @@
       this.value = this.inputText || '';
     }
     this.editing = false;
-    // TODO: This event seems to be unused (no listener). Remove?
     fire(this, 'changed', this.value);
   }
 
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 95b6851..917ed1d 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
@@ -119,7 +119,7 @@
           white-space: normal;
           /* prose will automatically wrap but inline <code> blocks won't and we
            should overflow in that case rather than wrapping or leaking out */
-          overflow-x: auto;
+          overflow-x: var(--gr-formatted-text-markdown-html-overflow-x, auto);
           overflow-wrap: break-word;
         }
       `,
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 0250d82..5abf4fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -6,6 +6,7 @@
 import {
   AnnotationPluginApi,
   CoverageProvider,
+  DiffLayerFactory,
   TokenHoverListener,
 } from '../../../api/annotation';
 import {PluginApi} from '../../../api/plugin';
@@ -36,4 +37,12 @@
       listener,
     });
   }
+
+  addDiffLayer(factory: DiffLayerFactory): void {
+    this.reporting.trackApi(this.plugin, 'annotation', 'addDiffLayer');
+    this.pluginsModel.diffLayerRegister({
+      pluginName: this.plugin.getPluginName(),
+      factory,
+    });
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 6ee3bf3..145be54 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -217,6 +217,11 @@
   //   only skip the check if the caller wants to prosess status themselves.
   reportServerError?: boolean;
   isHighPriority?: boolean;
+  /**
+   * If true, the request will be routed through the readScheduler even if it
+   * uses a write method (PUT, POST, DELETE).
+   */
+  useReadScheduler?: boolean;
 }
 
 export interface FetchOptionsInit {
@@ -264,12 +269,20 @@
     private readonly writeScheduler: Scheduler<Response>
   ) {}
 
+  private isWrite(method: string, useReadScheduler?: boolean): boolean {
+    return (
+      (method === 'PUT' || method === 'POST' || method === 'DELETE') &&
+      !useReadScheduler
+    );
+  }
+
   private schedule(
     method: string,
     task: Task<Response>,
-    name?: string
+    name?: string,
+    useReadScheduler?: boolean
   ): Promise<Response> {
-    if (method === 'PUT' || method === 'POST' || method === 'DELETE') {
+    if (this.isWrite(method, useReadScheduler)) {
       return this.writeScheduler.schedule(task, name);
     } else {
       return this.readScheduler.schedule(task, name);
@@ -284,8 +297,7 @@
     const method = req.fetchOptions?.method ?? HttpMethod.GET;
     const startTime = Date.now();
 
-    const isWrite =
-      method === 'PUT' || method === 'POST' || method === 'DELETE';
+    const isWrite = this.isWrite(method, req.useReadScheduler);
     const origin = req.fetchOptions?.headers?.get(REQUEST_ORIGIN_HEADER);
     const pluginName = origin?.startsWith('plugin:') ? origin : undefined;
     const requestName = `${method} - ${
@@ -330,7 +342,9 @@
     };
 
     const resPromise = (
-      req.isHighPriority ? task() : this.schedule(method, task, requestName)
+      req.isHighPriority
+        ? task()
+        : this.schedule(method, task, requestName, req.useReadScheduler)
     ).catch((err: unknown) => {
       if (err instanceof RetryError) {
         return err.payload;
@@ -414,6 +428,9 @@
       fetchOptions: req.fetchOptions,
       anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
     };
+    if (req.useReadScheduler !== undefined) {
+      fetchReq.useReadScheduler = req.useReadScheduler;
+    }
 
     let resp: Response;
     try {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 92feb24..ea79611 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -130,6 +130,33 @@
       });
       assert.equal(readScheduler.scheduled.length, 0);
       await assertWriteRequest();
+      const res = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+
+    test('POST are sent to writeScheduler', async () => {
+      const promise = helper.fetch({
+        fetchOptions: {
+          method: HttpMethod.POST,
+        },
+        url: '/dummy/url',
+      });
+      assert.equal(readScheduler.scheduled.length, 0);
+      await assertWriteRequest();
+      const res = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+
+    test('POST with useReadScheduler are sent to readScheduler', async () => {
+      const promise = helper.fetch({
+        fetchOptions: {
+          method: HttpMethod.POST,
+        },
+        url: '/changes/123/revisions/1/fix:preview',
+        useReadScheduler: true,
+      });
+      assert.equal(writeScheduler.scheduled.length, 0);
+      await assertReadRequest();
       const res: Response = await promise;
       assert.equal(await res.text(), 'Yay');
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 0c9be74..54aecb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -27,6 +27,9 @@
   @property({type: String})
   tooltip = '';
 
+  @property({type: Boolean, attribute: 'disable-auto-select', reflect: true})
+  disableAutoSelect = false;
+
   static override get styles() {
     return [
       sharedStyles,
@@ -73,6 +76,7 @@
           .text=${this.command}
           hasTooltip
           buttonTitle=${this.tooltip}
+          .disableAutoSelect=${this.disableAutoSelect}
         ></gr-copy-clipboard>
       </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index 1e0ff11..8fb66f5 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -44,4 +44,14 @@
     await element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
+
+  test('passes disableAutoSelect to gr-copy-clipboard', async () => {
+    element.disableAutoSelect = true;
+    await element.updateComplete;
+    const copyClipboard = queryAndAssert<GrCopyClipboard>(
+      element,
+      'gr-copy-clipboard'
+    );
+    assert.isTrue(copyClipboard.disableAutoSelect);
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 72e0905..0b46706 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -30,7 +30,11 @@
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {fire, fireError} from '../../../utils/event-util';
 import {Timing} from '../../../constants/reporting';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createApplyFixUrl,
+} from '../../../models/views/change';
+
 import {getFileExtension} from '../../../utils/file-util';
 import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {ReportSource} from '../../../services/suggestions/suggestions-service';
@@ -128,6 +132,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -391,16 +397,23 @@
     // basePatchNum is from comment patchset and comment cannot be created
     // in EDIT. RevisionPatchset without EDIT is PatchSetNumber
     if (res?.ok && basePatchNum !== undefined && basePatchNum !== EDIT) {
+      const currentChildView = this.getViewModel().getState()?.childView;
+      const filePath =
+        fixSuggestion.replacements[0]?.path ?? this.preview?.filepath;
       this.getNavigation().setUrl(
-        createChangeUrl({
+        createApplyFixUrl({
           changeNum,
           repo: this.repo!,
-          patchNum: EDIT,
           basePatchNum: basePatchNum as PatchSetNumber,
           forceReload: !this.hasEdit,
+          filePath,
+          currentChildView,
         })
       );
-      fire(this, 'reload-diff', {path: fixSuggestion.replacements[0].path});
+
+      if (filePath) {
+        fire(this, 'reload-diff', {path: filePath});
+      }
       fire(this, 'apply-user-suggestion', {
         fixSuggestion: fixSuggestion.description.includes(
           ReportSource.GET_AI_FIX_FOR_COMMENT
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index 742900f..da0220c 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -17,8 +17,21 @@
 } from '../../../test/test-data-generators';
 import {getAppContext} from '../../../services/app-context';
 import {GrSuggestionDiffPreview} from './gr-suggestion-diff-preview';
-import {stubFlags} from '../../../test/test-utils';
-import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api';
+import * as sinon from 'sinon';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {stubFlags, stubRestApi} from '../../../test/test-utils';
+import {
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../../api/rest-api';
+import {changeViewModelToken} from '../../../models/views/change';
+import {
+  createChangeViewState,
+  createDiffViewState,
+  createRange,
+} from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-suggestion-diff-preview tests', () => {
   let element: GrSuggestionDiffPreview;
@@ -121,4 +134,53 @@
       {ignoreAttributes: ['style']}
     );
   });
+
+  suite('applyFix navigation', () => {
+    let setUrlStub: sinon.SinonStub;
+
+    setup(() => {
+      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response(null, {status: 200}))
+      );
+      element.changeNum = 42 as NumericChangeId;
+      element.repo = 'test-project' as RepoName;
+      element.patchSet = 1 as RevisionPatchSetNum;
+
+      element.fixSuggestionInfo = {
+        ...createFixSuggestionInfo(),
+        replacements: [
+          {
+            path: 'foo/bar.ts',
+            replacement: 'new content',
+            range: createRange(),
+          },
+        ],
+      };
+    });
+
+    test('navigates to createDiffUrl when in Diff View', async () => {
+      testResolver(changeViewModelToken).setState(createDiffViewState());
+
+      await element.applyFix();
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1..edit/foo/bar.ts?forceReload=true'
+      );
+    });
+
+    test('navigates to createChangeUrl when in Change View', async () => {
+      testResolver(changeViewModelToken).setState(createChangeViewState());
+
+      await element.applyFix();
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1..edit?forceReload=true'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index c986efe..3fbc552 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -321,9 +321,18 @@
     );
     this.checksLatest$ = select(this.state$, state => state.pluginStateLatest);
     this.checksSelected$ = select(
-      combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
-      ([state, ps]) => {
-        const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
+      combineLatest([
+        this.state$,
+        this.changeViewModel.checksPatchset$,
+        this.changeModel.latestPatchNum$,
+      ]),
+      ([state, ps, latestPs]) => {
+        // When no distinct patchset is selected SELECTED fetch is skipped
+        // (see initFetchingOfData), so fall back to the LATEST state here.
+        const checksPs =
+          ps && ps !== latestPs
+            ? ChecksPatchset.SELECTED
+            : ChecksPatchset.LATEST;
         return this.getPluginState(state, checksPs);
       }
     );
@@ -865,29 +874,54 @@
         this.reloadSubjects[pluginName],
         pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
         this.documentVisibilityChange$,
+        // Only the SELECTED subscription needs the latest patchset here, to
+        // detect when it coincides with the latest (see below). The LATEST
+        // subscription already has it as its second element, so feeding it in
+        // again would make combineLatest emit twice per change.
+        patchset === ChecksPatchset.SELECTED
+          ? this.changeModel.latestPatchNum$
+          : of(undefined),
       ])
         .pipe(
           takeWhile(_ => !!this.providers[pluginName]),
           filter(_ => document.visibilityState !== 'hidden'),
           throttleTime(500, undefined, {leading: true, trailing: true}),
-          switchMap(([change, patchNum]): Observable<FetchResponse> => {
-            if (!change || !patchNum) return of(this.empty());
-            if (typeof patchNum !== 'number') return of(this.empty());
-            assertIsDefined(change.revisions, 'change.revisions');
-            const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
-            // Sometimes patchNum is updated earlier than change, so change
-            // revisions don't have patchNum yet
-            if (!patchsetSha) return of(this.empty());
-            const data: ChangeData = {
-              changeNumber: change?._number,
-              patchsetNumber: patchNum,
-              patchsetSha,
-              repo: change.project,
-              commitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change as ChangeInfo,
-            };
-            return this.fetchResults(pluginName, data, patchset);
-          }),
+          switchMap(
+            ([
+              change,
+              patchNum,
+              ,
+              ,
+              ,
+              latestPatchNum,
+            ]): Observable<FetchResponse> => {
+              if (!change || !patchNum) return of(this.empty());
+              if (typeof patchNum !== 'number') return of(this.empty());
+              // Skip the duplicate fetch when the selected patchset is the
+              // latest: the LATEST subscription already fetches it and
+              // checksSelected$ falls back to that state.
+              if (
+                patchset === ChecksPatchset.SELECTED &&
+                patchNum === latestPatchNum
+              ) {
+                return of(this.empty());
+              }
+              assertIsDefined(change.revisions, 'change.revisions');
+              const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+              // Sometimes patchNum is updated earlier than change, so change
+              // revisions don't have patchNum yet
+              if (!patchsetSha) return of(this.empty());
+              const data: ChangeData = {
+                changeNumber: change?._number,
+                patchsetNumber: patchNum,
+                patchsetSha,
+                repo: change.project,
+                commitMessage: getCurrentRevision(change)?.commit?.message,
+                changeInfo: change as ChangeInfo,
+              };
+              return this.fetchResults(pluginName, data, patchset);
+            }
+          ),
           catchError(e => {
             // This should not happen and is really severe, because it means that
             // the Observable has terminated and we won't recover from that. No
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 94b3d0f..368b7e6 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -17,6 +17,7 @@
 import {
   Action,
   Category,
+  ChangeData,
   CheckRun,
   ChecksApiConfig,
   ChecksProvider,
@@ -27,9 +28,11 @@
 import {
   createCheckResult,
   createParsedChange,
+  createRevisions,
   createRun,
+  getCurrentRevision,
 } from '../../test/test-data-generators';
-import {waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {waitEventLoop, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
 import {
   changeModelToken,
@@ -38,7 +41,11 @@
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {changeViewModelToken} from '../views/change';
-import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
+import {
+  NumericChangeId,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
 import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {deepEqual} from '../../utils/deep-util';
 
@@ -79,6 +86,29 @@
   };
 }
 
+/**
+ * A provider that echoes back the patchset it was asked to fetch, so that tests
+ * can assert which patchset a tab is populated with.
+ */
+function createPatchsetTaggingProvider(): ChecksProvider {
+  return {
+    fetch: (data: ChangeData) =>
+      Promise.resolve({
+        responseCode: ResponseCode.OK,
+        runs: [createRun({patchset: data.patchsetNumber})],
+      }),
+  };
+}
+
+/** A change with two patchsets, so latest (2) and older (1) are distinct. */
+function createTwoPatchsetChange(): ParsedChangeInfo {
+  return updateRevisionsWithCommitShas({
+    ...createParsedChange(),
+    revisions: createRevisions(2),
+    current_revision: getCurrentRevision(1),
+  })!;
+}
+
 suite('checks-model tests', () => {
   let model: ChecksModel;
 
@@ -130,6 +160,130 @@
     clock.restore();
   });
 
+  test('no duplicate fetch when viewing latest patchset (no selection)', async () => {
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    let latestRuns: CheckRun[] = [];
+    let selectedRuns: CheckRun[] = [];
+    model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+    model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+    const provider = createPatchsetTaggingProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: PLUGIN_NAME,
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    // Viewing the latest patchset (2), no explicit checks patchset override.
+    testResolver(changeViewModelToken).updateState({
+      patchNum: 2 as RevisionPatchSetNum,
+    });
+    const testChange = createTwoPatchsetChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => deepEqual(change, testChange));
+
+    // Fire the throttled emission, then flush the fetch promise into state.
+    clock.tick(600);
+    await waitEventLoop();
+
+    // The SELECTED patchset equals LATEST, so only a single fetch is needed.
+    assert.equal(fetchSpy.callCount, 1);
+    // Both tabs show data for the latest patchset (2).
+    assert.isNotEmpty(latestRuns);
+    assert.isNotEmpty(selectedRuns);
+    assert.isTrue(latestRuns.every(r => r.patchset === 2));
+    assert.isTrue(selectedRuns.every(r => r.patchset === 2));
+
+    clock.restore();
+  });
+
+  test('no duplicate fetch when latest patchset is explicitly selected', async () => {
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    let latestRuns: CheckRun[] = [];
+    let selectedRuns: CheckRun[] = [];
+    model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+    model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+    const provider = createPatchsetTaggingProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: PLUGIN_NAME,
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    // Viewing patchset 1 but explicitly selecting the latest patchset (2) in the
+    // checks tab. checksPatchset differs from patchNum, so it is not reset.
+    testResolver(changeViewModelToken).updateState({
+      patchNum: 1 as RevisionPatchSetNum,
+      checksPatchset: 2 as PatchSetNumber,
+    });
+    const testChange = createTwoPatchsetChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => deepEqual(change, testChange));
+
+    // Fire the throttled emission, then flush the fetch promise into state.
+    clock.tick(600);
+    await waitEventLoop();
+
+    // Selected patchset (2) equals latest, so still only a single fetch.
+    assert.equal(fetchSpy.callCount, 1);
+    assert.isNotEmpty(latestRuns);
+    assert.isNotEmpty(selectedRuns);
+    assert.isTrue(latestRuns.every(r => r.patchset === 2));
+    assert.isTrue(selectedRuns.every(r => r.patchset === 2));
+
+    clock.restore();
+  });
+
+  test('fetches both patchsets when an older one is selected', async () => {
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    let latestRuns: CheckRun[] = [];
+    let selectedRuns: CheckRun[] = [];
+    model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+    model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+    const provider = createPatchsetTaggingProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: PLUGIN_NAME,
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    // Explicitly selecting the older patchset (1); latest is 2.
+    testResolver(changeViewModelToken).updateState({
+      checksPatchset: 1 as PatchSetNumber,
+    });
+    const testChange = createTwoPatchsetChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => deepEqual(change, testChange));
+
+    // Fire the throttled emission, then flush the fetch promise into state.
+    clock.tick(600);
+    await waitEventLoop();
+
+    // Distinct patchsets require two fetches: one for latest, one for selected.
+    assert.equal(fetchSpy.callCount, 2);
+    // The latest tab shows patchset 2, the selected tab shows the older 1.
+    assert.isNotEmpty(latestRuns);
+    assert.isNotEmpty(selectedRuns);
+    assert.isTrue(latestRuns.every(r => r.patchset === 2));
+    assert.isTrue(selectedRuns.every(r => r.patchset === 1));
+
+    clock.restore();
+  });
+
   test('fetch throttle', async () => {
     const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     let change: ParsedChangeInfo | undefined = undefined;
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index f380422..239059b 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -23,7 +23,12 @@
 import {DraftInfo, FixSuggestionInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
-import {AiAgentEventDetails, Interaction} from '../../constants/reporting';
+import {
+  AiAgentChatEventDetails,
+  AiAgentCheckEventDetails,
+  AiAgentEventDetails,
+  Interaction,
+} from '../../constants/reporting';
 import {ReportingService as Reporting} from '../../services/gr-reporting/gr-reporting';
 import {createNew, PROVIDED_FIX_ID} from '../../utils/comment-util';
 import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
@@ -597,10 +602,10 @@
   return hasMessage || hasMultipleLinks || hasPointers || hasFixes;
 }
 
-function getAiAgentEventDetails(
+function getAiAgentChatEventDetails(
   runResult: RunResult,
   commentId?: string
-): AiAgentEventDetails | undefined {
+): AiAgentChatEventDetails | undefined {
   const externalId = runResult.externalId;
   if (!externalId) return;
   // Use JSON.parse. We expect agentId, conversationId, turnIndex.
@@ -620,7 +625,7 @@
     }
     /* eslint-disable object-shorthand */
     // prettier-ignore
-    const eventDetails: AiAgentEventDetails = {
+    const eventDetails: AiAgentChatEventDetails = {
       'agentId': agentId,
       'conversationId': conversationId,
       'turnIndex': Number(turnIndex),
@@ -634,6 +639,36 @@
   }
 }
 
+function getAiAgentCheckEventDetails(
+  runResult: RunResult,
+  commentId?: string
+): AiAgentCheckEventDetails | undefined {
+  if (!runResult.externalId) return;
+  if (!runResult.isAiPowered) return;
+  if (!runResult.checkName) return;
+  if (!runResult.checkDescription) return;
+
+  /* eslint-disable object-shorthand */
+  // prettier-ignore
+  const eventDetails: AiAgentCheckEventDetails = {
+    'checkName': runResult.checkName,
+    'checkDescription': runResult.checkDescription,
+    'externalId': runResult.externalId,
+    'commentId': commentId,
+  };
+  return eventDetails;
+  /* eslint-enable object-shorthand */
+}
+
+function getAiAgentEventDetails(
+  runResult: RunResult,
+  commentId?: string
+): AiAgentEventDetails | undefined {
+  const chatDetails = getAiAgentChatEventDetails(runResult, commentId);
+  if (chatDetails) return chatDetails;
+  return getAiAgentCheckEventDetails(runResult, commentId);
+}
+
 /**
  * Reports a "Get AI Fix" click interaction on a Code Review Agent check.
  */
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index 978a844..7359381 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -264,6 +264,9 @@
       } as unknown as ReportingService;
       const runResult = {
         ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
         externalId: JSON.stringify({
           agentId: 'test-agent',
           conversationId: 'test-conv',
@@ -294,6 +297,9 @@
       } as unknown as ReportingService;
       const runResult = {
         ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
         externalId: JSON.stringify({
           agentId: 'test-agent',
           conversationId: 'test-conv',
@@ -321,7 +327,10 @@
       const reporting = {
         reportInteraction: reportInteractionStub,
       } as unknown as ReportingService;
-      const runResult = createRunResult();
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+      };
 
       reportAiAgentGetAIFix(reporting, runResult);
       assert.isFalse(reportInteractionStub.called);
@@ -330,29 +339,86 @@
       assert.isFalse(reportInteractionStub.called);
     });
 
-    test('does not report if externalId is invalid JSON', () => {
+    test('does not report if isAiPowered is false or missing', () => {
       const reporting = {
         reportInteraction: reportInteractionStub,
       } as unknown as ReportingService;
       const runResult = {
         ...createRunResult(),
-        externalId: 'invalid-json',
+        isAiPowered: false,
+        externalId: 'some-id',
       };
 
       reportAiAgentGetAIFix(reporting, runResult);
       assert.isFalse(reportInteractionStub.called);
+
+      const runResultMissing = {
+        ...createRunResult(),
+        isAiPowered: undefined,
+        externalId: 'some-id',
+      };
+
+      reportAiAgentCommentDraft(reporting, runResultMissing);
+      assert.isFalse(reportInteractionStub.called);
     });
 
-    test('does not report if externalId is missing required fields', () => {
+    test('reports check event details if externalId is plain string (not chat JSON)', () => {
       const reporting = {
         reportInteraction: reportInteractionStub,
       } as unknown as ReportingService;
       const runResult = {
         ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: 'plain-string-external-id',
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.deepEqual(reportInteractionStub.lastCall.args[1], {
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: 'plain-string-external-id',
+        commentId: undefined,
+      });
+    });
+
+    test('reports check event details if externalId is JSON but missing chat fields', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
         externalId: JSON.stringify({agentId: 'test-agent'}),
       };
 
       reportAiAgentGetAIFix(reporting, runResult);
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.deepEqual(reportInteractionStub.lastCall.args[1], {
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: runResult.externalId,
+        commentId: undefined,
+      });
+    });
+
+    test('does not report check event if checkDescription is missing', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: undefined,
+        externalId: 'plain-string-external-id',
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
       assert.isFalse(reportInteractionStub.called);
     });
   });
diff --git a/polygerrit-ui/app/models/flows/flows-model.ts b/polygerrit-ui/app/models/flows/flows-model.ts
index 300a1b7..83d0c74 100644
--- a/polygerrit-ui/app/models/flows/flows-model.ts
+++ b/polygerrit-ui/app/models/flows/flows-model.ts
@@ -31,12 +31,26 @@
 
 export const SUBMIT_ACTION_NAME = 'submit';
 
+/**
+ * Matches the base change path prefix up to the change number (e.g. `/c/project/+/123`),
+ * ignoring any trailing patchsets, diff ranges, comment IDs, or file paths.
+ *
+ * @see RoutePattern.CHANGE in `gr-router.ts` for corresponding route pattern.
+ */
+const CHANGE_PREFIX_PATTERN = /^(.*?\/(?:c\/.+?\/\+)\/\d+)/;
+
 export function getSubmitCondition() {
   return getChangePrefix() + ' is is:submittable';
 }
 
+/**
+ * Returns the change URL prefix (e.g. `http://host/c/project/+/123`), stripping
+ * patchset numbers, diff ranges, comment IDs, and file paths from pathname.
+ */
 export function getChangePrefix() {
-  return window.location.origin + window.location.pathname;
+  const match = window.location.pathname.match(CHANGE_PREFIX_PATTERN);
+  const pathname = match ? match[1] : window.location.pathname;
+  return window.location.origin + pathname;
 }
 
 export class FlowsModel extends Model<FlowsState> {
diff --git a/polygerrit-ui/app/models/flows/flows-model_test.ts b/polygerrit-ui/app/models/flows/flows-model_test.ts
index 2dfe5a7..18c9538 100644
--- a/polygerrit-ui/app/models/flows/flows-model_test.ts
+++ b/polygerrit-ui/app/models/flows/flows-model_test.ts
@@ -200,4 +200,69 @@
     await waitUntil(() => !flowsModel.hasAutosubmitFlowAlready());
     assert.isFalse(flowsModel.hasAutosubmitFlowAlready());
   });
+
+  suite('getChangePrefix', () => {
+    let originalPath: string;
+
+    setup(() => {
+      originalPath = window.location.pathname;
+    });
+
+    teardown(() => {
+      window.history.replaceState(null, '', originalPath);
+    });
+
+    test('strips patchset, diff range, and file path subpaths', () => {
+      const origin = window.location.origin;
+
+      window.history.replaceState(null, '', '/c/my-repo/+/123');
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+
+      window.history.replaceState(null, '', '/c/my-repo/+/123/4');
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+
+      window.history.replaceState(null, '', '/c/my-repo/+/123/1..4');
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+
+      window.history.replaceState(
+        null,
+        '',
+        '/c/my-repo/+/123/1..4/src/file.ts'
+      );
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+    });
+  });
+
+  test('hasAutosubmitFlowAlready detects flows when navigating between patchsets/diff ranges', async () => {
+    const originalPath = window.location.pathname;
+    try {
+      window.history.replaceState(null, '', '/c/my-repo/+/123/4..5/src/foo.ts');
+      stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true});
+      const expectedPrefix = `${window.location.origin}/c/my-repo/+/123`;
+      stubRestApi('listFlows').resolves([
+        createFlow({
+          uuid: 'flow1',
+          stages: [
+            {
+              expression: {
+                condition: `${expectedPrefix} is is:submittable`,
+                action: {name: SUBMIT_ACTION_NAME},
+              },
+              state: FlowStageState.DONE,
+            },
+          ],
+        }),
+      ]);
+
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        _number: 123 as NumericChangeId,
+      });
+      await waitUntil(() => flowsModel.getState().flows.length > 0);
+
+      assert.isTrue(flowsModel.hasAutosubmitFlowAlready());
+    } finally {
+      window.history.replaceState(null, '', originalPath);
+    }
+  });
 });
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 8ed7cfe..9074c21 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -12,7 +12,11 @@
 } from '../../api/checks';
 import {Model} from '../base/model';
 import {select} from '../../utils/observable-util';
-import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
+import {
+  CoverageProvider,
+  DiffLayerFactory,
+  TokenHoverListener,
+} from '../../api/annotation';
 import {SuggestionsProvider} from '../../api/suggestions';
 import {ChangeUpdatesPublisher} from '../../api/change-updates';
 import {AiCodeReviewProvider} from '../../api/ai-code-review';
@@ -59,6 +63,11 @@
   listener: TokenHoverListener;
 }
 
+export interface DiffLayerPlugin {
+  pluginName: string;
+  factory: DiffLayerFactory;
+}
+
 export interface ChecksUpdate {
   pluginName: string;
   run: CheckRun;
@@ -111,6 +120,11 @@
    * annotationApi().addTokenHoverListener().
    */
   tokenHighlightPlugins: TokenHoverListenerPlugin[];
+
+  /**
+   * List of plugins that have registered a diff layer factory.
+   */
+  diffLayerPlugins: DiffLayerPlugin[];
 }
 
 export class PluginsModel extends Model<PluginsState> {
@@ -154,6 +168,11 @@
     state => state.suggestionsPlugins
   );
 
+  public diffLayerPlugins$ = select(
+    this.state$,
+    state => state.diffLayerPlugins
+  );
+
   public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
 
   constructor() {
@@ -167,6 +186,7 @@
       flowsAutosubmitPlugins: [],
       suggestionsPlugins: [],
       tokenHighlightPlugins: [],
+      diffLayerPlugins: [],
     });
   }
 
@@ -302,6 +322,22 @@
     this.setState(nextState);
   }
 
+  diffLayerRegister(plugin: DiffLayerPlugin) {
+    const nextState = {...this.getState()};
+    nextState.diffLayerPlugins = [...nextState.diffLayerPlugins];
+    const alreadyRegistered = nextState.diffLayerPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a diff layer provider. Ignored.`
+      );
+      return;
+    }
+    nextState.diffLayerPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   checksUpdate(update: ChecksUpdate) {
     const plugins = this.getState().checksPlugins;
     const plugin = plugins.find(p => p.pluginName === update.pluginName);
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
index 7aabae7..bdcb8b1 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model_test.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -4,9 +4,15 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../test/common-test-setup';
+import * as sinon from 'sinon';
 import './plugins-model';
 import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
-import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
+import {
+  ChecksPlugin,
+  ChecksUpdate,
+  DiffLayerPlugin,
+  PluginsModel,
+} from './plugins-model';
 import {createRun, createRunResult} from '../../test/test-data-generators';
 import {assert} from '@open-wc/testing';
 
@@ -29,6 +35,7 @@
 suite('plugins-model tests', () => {
   let model: PluginsModel;
   let checksPlugins: ChecksPlugin[] = [];
+  let diffLayerPlugins: DiffLayerPlugin[] = [];
   const register = function () {
     model.checksRegister({
       pluginName: PLUGIN_NAME,
@@ -41,6 +48,7 @@
     model = new PluginsModel();
     model.state$.subscribe(s => {
       checksPlugins = s.checksPlugins;
+      diffLayerPlugins = s.diffLayerPlugins;
     });
   });
 
@@ -81,4 +89,33 @@
 
     assert.equal(update?.pluginName, PLUGIN_NAME);
   });
+
+  test('diffLayerRegister', async () => {
+    assert.isFalse(diffLayerPlugins.some(p => p.pluginName === PLUGIN_NAME));
+
+    const factory = () => {
+      return {
+        annotate: () => {},
+      };
+    };
+    model.diffLayerRegister({
+      pluginName: PLUGIN_NAME,
+      factory,
+    });
+
+    assert.isTrue(diffLayerPlugins.some(p => p.pluginName === PLUGIN_NAME));
+    assert.equal(
+      diffLayerPlugins.find(p => p.pluginName === PLUGIN_NAME)?.factory,
+      factory
+    );
+
+    // Try to register again
+    const consoleWarnStub = sinon.stub(console, 'warn');
+    model.diffLayerRegister({
+      pluginName: PLUGIN_NAME,
+      factory,
+    });
+    assert.isTrue(consoleWarnStub.calledOnce);
+    consoleWarnStub.restore();
+  });
 });
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 03c944f..0ee5e99 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -253,6 +253,9 @@
   ) {
     params.push(`checksPatchset=${state.checksPatchset}`);
   }
+  if (state.forceReload) {
+    params.push('forceReload=true');
+  }
   if (params.length > 0) {
     queryParams = '?' + params.join('&');
   }
@@ -285,6 +288,26 @@
   return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
 }
 
+export function createApplyFixUrl(
+  obj: (CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>) & {
+    filePath?: string;
+    currentChildView?: ChangeChildView;
+  }
+): string {
+  const {filePath, currentChildView, ...restObj} = obj;
+  if (currentChildView === ChangeChildView.DIFF && filePath) {
+    return createDiffUrl({
+      ...restObj,
+      patchNum: EDIT,
+      diffView: {path: filePath},
+    });
+  }
+  return createChangeUrl({
+    ...restObj,
+    patchNum: EDIT,
+  });
+}
+
 /**
  * The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
  * child views.
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index e05adb6..8bebe21 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,10 +6,13 @@
 import {assert} from '@open-wc/testing';
 import {
   BasePatchSetNum,
+  EDIT,
+  NumericChangeId,
   PatchSetNumber,
   RepoName,
   RevisionPatchSetNum,
 } from '../../api/rest-api';
+
 import '../../test/common-test-setup';
 import {
   createChangeViewState,
@@ -17,7 +20,9 @@
   createEditViewState,
 } from '../../test/test-data-generators';
 import {
+  ChangeChildView,
   ChangeViewState,
+  createApplyFixUrl,
   createChangeUrl,
   createDiffUrl,
   createEditUrl,
@@ -114,6 +119,14 @@
       );
     });
 
+    test('forceReload', () => {
+      params.forceReload = true;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/x%252By/path.cpp?forceReload=true'
+      );
+    });
+
     test('base patchset', () => {
       params.basePatchNum = 6 as BasePatchSetNum;
       assert.equal(
@@ -186,4 +199,36 @@
     assert.equal(createEditUrl(params).substring(0, 5), '/base');
     window.CANONICAL_PATH = undefined;
   });
+
+  suite('createApplyFixUrl', () => {
+    test('Diff View context', () => {
+      assert.equal(
+        createApplyFixUrl({
+          changeNum: 42 as NumericChangeId,
+          repo: 'test-project' as RepoName,
+          patchNum: EDIT,
+          basePatchNum: 1 as BasePatchSetNum,
+          forceReload: true,
+          filePath: 'foo/bar.ts',
+          currentChildView: ChangeChildView.DIFF,
+        }),
+        '/c/test-project/+/42/1..edit/foo/bar.ts?forceReload=true'
+      );
+    });
+
+    test('Overview context', () => {
+      assert.equal(
+        createApplyFixUrl({
+          changeNum: 42 as NumericChangeId,
+          repo: 'test-project' as RepoName,
+          patchNum: EDIT,
+          basePatchNum: 1 as BasePatchSetNum,
+          forceReload: true,
+          filePath: 'foo/bar.ts',
+          currentChildView: ChangeChildView.OVERVIEW,
+        }),
+        '/c/test-project/+/42/1..edit?forceReload=true'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/pnpm-lock.yaml b/polygerrit-ui/app/pnpm-lock.yaml
index da97189..6d865aa 100644
--- a/polygerrit-ui/app/pnpm-lock.yaml
+++ b/polygerrit-ui/app/pnpm-lock.yaml
@@ -138,15 +138,15 @@
     engines: {node: '>=12.0.0'}
 
   highlightjs-closure-templates@https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba:
-    resolution: {tarball: https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba}
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba}
     version: 0.0.1
 
   highlightjs-epp@https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9:
-    resolution: {tarball: https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9}
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9}
     version: 0.0.1
 
   highlightjs-structured-text@https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e:
-    resolution: {tarball: https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e}
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e}
     version: 1.4.9
 
   highlightjs-ttcn3@git+https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d:
@@ -154,7 +154,7 @@
     version: 0.0.1
 
   highlightjs-vue@https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04:
-    resolution: {tarball: https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04}
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04}
     version: 1.1.0
 
   ieee754@1.2.1:
@@ -224,7 +224,7 @@
     engines: {node: '>= 6'}
 
   resemblejs@https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917:
-    resolution: {tarball: https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917}
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917}
     version: 5.0.0
 
   rxjs@6.6.7:
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 92a9c02..51edcfc 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
@@ -2620,6 +2620,7 @@
           }),
           url: `${url}/fix:preview`,
           anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:preview`,
+          useReadScheduler: true,
         })) as FilePathToDiffInfoMap | undefined;
 
         if (response === undefined) {
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 730fc4d..8cd5dfb 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -1,6 +1,10 @@
 {
   "extends": "./tsconfig.json",
   "compilerOptions": {
+    // Override the yarn/IDE outDir from tsconfig.json: rules_ts requires the
+    // output directory to stay within the Bazel package (kept in sync with
+    // out_dir of the compile_pg ts_project in the BUILD file).
+    "outDir": "_pg_ts_out",
     "typeRoots": [
       "../../external/ui_npm/node_modules/@types",
       "../../external/ui_dev_npm/node_modules/@types"
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 7d74ffa..e6fc2cc 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -1,6 +1,10 @@
 {
   "extends": "./tsconfig_bazel.json",
   "compilerOptions": {
+    // Override the yarn/IDE outDir from tsconfig.json: rules_ts requires the
+    // output directory to stay within the Bazel package (kept in sync with
+    // out_dir of the compile_pg_with_tests ts_project).
+    "outDir": "_pg_with_tests_out",
     "typeRoots": [
       "node_modules/@types",
       "../node_modules/@types"
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 52a4794..2a2ec16 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -110,7 +110,6 @@
 
 export type ChangeEvent = InputEvent;
 
-// TODO: This event seems to be unused (no listener). Remove?
 export type ChangedEvent = CustomEvent<string | undefined>;
 
 export interface ChangeMessageDeletedEventDetail {
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 4b0c1ab..ef08cf0 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -96,15 +96,6 @@
   const path1 = c1.path ?? '';
   const path2 = c2.path ?? '';
   if (path1 !== path2) {
-    // TODO: Why is this logic not part of specialFilePathCompare()?
-    // '/PATCHSET' will not come before '/COMMIT' when sorting
-    // alphabetically so move it to the front explicitly
-    if (path1 === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return -1;
-    }
-    if (path2 === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return 1;
-    }
     return specialFilePathCompare(path1, path2);
   }
 
@@ -211,7 +202,7 @@
     path: replyingTo.path,
     patch_set: replyingTo.patch_set,
     side: replyingTo.side,
-    line: replyingTo.line,
+    line: replyingTo.range ? replyingTo.range.end_line : replyingTo.line,
     range: replyingTo.range,
     parent: replyingTo.parent,
     in_reply_to: replyingTo.id,
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index e8fe606..abda42c 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -8,6 +8,7 @@
   computeDisplayLine,
   createCommentThreads,
   createNew,
+  createNewReply,
   createUserFixSuggestion,
   getContentInCommentRange,
   getMentionedThreads,
@@ -748,4 +749,36 @@
       assert.equal(computeDisplayLine({}), '');
     });
   });
+
+  suite('createNewReply', () => {
+    test('standard comment reply (no range)', () => {
+      const replyingTo = {
+        ...createComment(),
+        id: 'parent_id' as UrlEncodedCommentId,
+        line: 5,
+      };
+      const reply = createNewReply(replyingTo, 'reply message', false);
+      assert.equal(reply.line, 5);
+      assert.isUndefined(reply.range);
+      assert.equal(reply.in_reply_to, 'parent_id');
+    });
+
+    test('reply to comment with range (mismatched line)', () => {
+      const replyingTo = {
+        ...createComment(),
+        id: 'parent_id' as UrlEncodedCommentId,
+        line: 1,
+        range: {
+          start_line: 1,
+          start_character: 0,
+          end_line: 2,
+          end_character: 5,
+        },
+      };
+      const reply = createNewReply(replyingTo, 'reply message', false);
+      assert.equal(reply.line, 2); // Should match range.end_line, not replyingTo.line
+      assert.deepEqual(reply.range, replyingTo.range);
+      assert.equal(reply.in_reply_to, 'parent_id');
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 5c523b3..31e17c9 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -8,6 +8,14 @@
 import {hasOwnProperty} from './common-util';
 
 export function specialFilePathCompare(a: string, b: string) {
+  // Patchset-level comments always go first.
+  if (a === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+    return -1;
+  }
+  if (b === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+    return 1;
+  }
+
   // The commit message always goes first.
   if (a === SpecialFilePath.COMMIT_MESSAGE) {
     return -1;
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index cdd8182..a7c1e53 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -21,12 +21,14 @@
     const testFiles = [
       '/a.h',
       '/MERGE_LIST',
+      SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
       '/a.cpp',
       '/COMMIT_MSG',
       '/asdasd',
       '/mrPeanutbutter.py',
     ];
     assert.deepEqual(testFiles.sort(specialFilePathCompare), [
+      SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
       '/COMMIT_MSG',
       '/MERGE_LIST',
       '/a.h',
diff --git a/polygerrit-ui/polygerrit.MODULE.bazel b/polygerrit-ui/polygerrit.MODULE.bazel
index d839cc8..e04ccd6 100644
--- a/polygerrit-ui/polygerrit.MODULE.bazel
+++ b/polygerrit-ui/polygerrit.MODULE.bazel
@@ -27,34 +27,12 @@
 )
 use_repo(rules_ts_ext, "npm_typescript")
 
-NODE_24_REPO = {
-    "24.14.1-darwin_arm64": ("node-v24.14.1-darwin-arm64.tar.gz", "node-v24.14.1-darwin-arm64", "25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"),
-    "24.14.1-darwin_amd64": ("node-v24.14.1-darwin-x64.tar.gz", "node-v24.14.1-darwin-x64", "2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"),
-    "24.14.1-linux_arm64": ("node-v24.14.1-linux-arm64.tar.xz", "node-v24.14.1-linux-arm64", "71e427e28b78846f201d4d5ecc30cb13d1508ca099ef3871889a1256c7d6f67e"),
-    "24.14.1-linux_ppc64le": ("node-v24.14.1-linux-ppc64le.tar.xz", "node-v24.14.1-linux-ppc64le", "95bf0c8dbb73144edb79a57399f03c70af6995b78e1c632926e53e6404662ef5"),
-    "24.14.1-linux_s390x": ("node-v24.14.1-linux-s390x.tar.xz", "node-v24.14.1-linux-s390x", "ed3bfbc0ff418b0ec4633f23d53a12a691717a34b041c3fbdb296c8774e5a98a"),
-    "24.14.1-linux_amd64": ("node-v24.14.1-linux-x64.tar.xz", "node-v24.14.1-linux-x64", "84d38715d449447117d05c3e71acd78daa49d5b1bfa8aacf610303920c3322be"),
-    "24.14.1-windows_amd64": ("node-v24.14.1-win-x64.zip", "node-v24.14.1-win-x64", "6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"),
-}
-
+# The node version is read from //:.nvmrc, the cross-tool convention file
+# also honored by nvm and CI -- Bazel and the yarn dev flow share one pin.
+# Versions unknown to the pinned rules_nodejs release are auto-fetched and
+# recorded in MODULE.bazel.lock (rules_nodejs >= 6.7.5).
 node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
-node.toolchain(
-    name = "nodejs",
-    node_repositories = NODE_24_REPO,
-    node_version = "24.14.1",
-)
-use_repo(node, "nodejs_toolchains")
-use_repo(node, "nodejs_darwin_arm64")
-use_repo(node, "nodejs_darwin_amd64")
-use_repo(node, "nodejs_linux_arm64")
-use_repo(node, "nodejs_linux_ppc64le")
-use_repo(node, "nodejs_linux_s390x")
-use_repo(node, "nodejs_linux_amd64")
-use_repo(node, "nodejs_windows_amd64")
-
-pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
-pnpm.pnpm(pnpm_version = "10.33.3")
-use_repo(pnpm, "pnpm")
+node.toolchain(node_version_from_nvmrc = "//:.nvmrc")
 
 npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
 npm.npm_translate_lock(
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png
index 7bbdc5b..436031d 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png
index ff592ae..9aca5a8 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png
index 782a764..1cd5d8d 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png
index 1368d3d..569b3bb 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png
new file mode 100644
index 0000000..0f4857e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png
new file mode 100644
index 0000000..2464d3f
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png
new file mode 100644
index 0000000..238c3fb
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png
new file mode 100644
index 0000000..ff32fb3
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal-dark.png
new file mode 100644
index 0000000..fe699cb
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal.png
new file mode 100644
index 0000000..d6df16b
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal-dark.png
new file mode 100644
index 0000000..dc9928d
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal.png
new file mode 100644
index 0000000..c4b94d4
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal.png
Binary files differ
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 2a6f695..804fd9a 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -9,7 +9,9 @@
 import { playwrightLauncher } from '@web/test-runner-playwright';
 
 const runUnderBazel = !!process.env['RUNFILES_DIR'];
-const diffThreshold = 0.01;
+// We set this to a non-zero value because of sub-pixel rendering noise that
+// can create false positives.
+const diffThreshold = .02;
 
 function testRunnerHtmlFactory(prefix) {
   return (testFramework) => `
@@ -36,11 +38,11 @@
     ];
   }
 
-  const runfilesRoot = path.dirname(process.cwd());
+  const cwd = process.cwd();
   return [
-    path.join(runfilesRoot, 'plugins_npm', 'node_modules'),
-    path.join(runfilesRoot, 'ui_npm', 'node_modules'),
-    path.join(runfilesRoot, 'ui_dev_npm', 'node_modules'),
+    path.join(cwd, 'plugins/node_modules'),
+    path.join(cwd, 'polygerrit-ui/node_modules'),
+    path.join(cwd, 'polygerrit-ui/app/node_modules'),
   ];
 }
 
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 17e0fcc..06f07b5 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -87,7 +87,9 @@
   {else}
     <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
   {/if}
-  <link rel="manifest" href="{$manifestPath}">{\n}
+  {if $manifestPath}
+    <link rel="manifest" href="{$manifestPath}">{\n}
+  {/if}
   {if $changeRequestsPath}
     {if $defaultChangeDetailHex}
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultChangeDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 20f9999..a485abd 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.emailRegistrationLink}
+    <a href="{$email.emailRegistrationLink}">{$email.emailRegistrationLink}</a>
   </p>
   <p>
     If you have received this mail in error, you do not need to take any
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 66f21b9..f557cfd 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -244,6 +244,7 @@
 svg = application/xml
 svh = text/x-systemverilog
 swift = text/x-swift
+tada = text/x-python
 tcl = text/x-tcl
 tex = text/x-latex
 text = text/plain
diff --git a/tools/bazlets.MODULE.bazel b/tools/bazlets.MODULE.bazel
index 49e736d..8bf405d 100644
--- a/tools/bazlets.MODULE.bazel
+++ b/tools/bazlets.MODULE.bazel
@@ -6,7 +6,7 @@
 bazel_dep(name = "com_googlesource_gerrit_bazlets")
 git_override(
     module_name = "com_googlesource_gerrit_bazlets",
-    commit = "06126c5e41d7a94f2b6d11af8e7e97a490b6e310",
+    commit = "2366eccc72aac1a966ef3446c98a80230a7b5d89",
     remote = "https://gerrit.googlesource.com/bazlets",
 )
 
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index ebc2c1e..e99384b 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -71,6 +71,9 @@
     "libgerrit-prolog-common",
     "libjgit-archive",
     "libjgit-servlet",
+    # Core WARs do not package JGit LFS server jars today. If that changes,
+    # exclude both the canonical and EE8 LFS jars here as Gerrit-internal jars.
+    "libjgit-servlet-ee8",
     "libquery_parser",
     "libssh-apache",
     "log4j-config",
diff --git a/tools/deps.toml b/tools/deps.toml
index 6444daf..e500cd6 100644
--- a/tools/deps.toml
+++ b/tools/deps.toml
@@ -2,13 +2,13 @@
 antlr = "3.5.2"
 autoValueGson = "1.3.1"
 bouncyCastle = "1.84"
-byteBuddy = "1.18.10"
+byteBuddy = "1.18.11"
 caffeine = "2.9.2"
 commonmark = "0.24.0"
 gitiles = "1.6.0"
 greenmail = "1.5.5"
 httpcomp = "4.5.14"
-jetty = "12.1.10"
+jetty = "12.1.11"
 mail = "1.6.0"
 mime4j = "0.8.1"
 ow2 = "9.9.1"
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 12567ba..6bd0763 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -200,8 +200,6 @@
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.archive/src',
             excluding='org/eclipse/jgit/archive/FormatActivator.java')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.archive/resources')
-        classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/src')
-        classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/resources')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.junit/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/resources')
@@ -261,6 +259,21 @@
     for p in source_cp:
         source_by_basename[_normalize_jar_basename(p)] = p
 
+    generated_jgit_sources = {
+        "org.eclipse.jgit.http.server.ee8/libjgit-servlet-ee8.jar":
+            "org.eclipse.jgit.http.server.ee8/jgit-http-server-ee8-srcs.srcjar",
+        "org.eclipse.jgit.lfs.server.ee8/libjgit-lfs-server-ee8.jar":
+            "org.eclipse.jgit.lfs.server.ee8/jgit-lfs-server-ee8-srcs.srcjar",
+    }
+
+    def generated_jgit_source(jar):
+        for jar_suffix, src_suffix in generated_jgit_sources.items():
+            if jar.endswith(jar_suffix):
+                candidate = jar[:-len(jar_suffix)] + src_suffix
+                if os.path.exists(candidate):
+                    return candidate
+        return None
+
     for p in runtime_cp:
         if p.endswith('-src.jar'):
             continue
@@ -339,12 +352,12 @@
             if os.path.basename(j) == "libjava-prettify.jar" and "/external/" in j:
                 continue
 
-            s = None
+            s = generated_jgit_source(j)
 
             # Attach sources using the classpath_collector output from rules_jvm_external.
             # This replaces the previous heuristic-based source lookup.
             key = _normalize_jar_basename(j)
-            if key in source_by_basename:
+            if not s and key in source_by_basename:
                 sp = _resolve_repo_path(output_base, source_by_basename[key])
                 s = sp
 
@@ -441,4 +454,3 @@
 except KeyboardInterrupt:
     print('Interrupted by user', file=sys.stderr)
     exit(1)
-
diff --git a/tools/java_deps.MODULE.bazel b/tools/java_deps.MODULE.bazel
index a899132..378b378 100644
--- a/tools/java_deps.MODULE.bazel
+++ b/tools/java_deps.MODULE.bazel
@@ -137,6 +137,38 @@
     for coord in _GERRIT_FORCED_ARTIFACTS
 ]
 
+# We consume Guice's unshaded artifact (published as the "classes"
+# classifier) plus an external OW2 ASM (see //lib/guice and tools/nongoogle.toml),
+# because the ASM shaded into the default guice jar cannot read Java 25 class
+# files and corrupts Guice's error-message source formatting. The external ASM
+# must be >= 9.8, the first release with Java 25 (class major 69) support;
+# Gerrit pins 9.9.1.
+#
+# That only holds if the plain (fat) com.google.inject:guice never reaches the
+# classpath. These artifacts pull it in transitively, so strip that edge from
+# each; they resolve Guice solely from the //lib/guice classes-classifier jar.
+# Keep this list in sync with everything that depends on Guice transitively --
+# a missed entry silently reintroduces the shaded ASM.
+#
+# TODO(davido): Drop these exclusions (and the classes classifier) once Guice
+# ships a default jar with an updated shaded ASM that reads current class
+# files: https://github.com/google/guice/issues/1926
+_GUICE_FAT_JAR_CONSUMERS = [
+    "com.google.inject.extensions:guice-assistedinject",
+    "com.google.inject.extensions:guice-servlet",
+    "com.google.template:soy",
+    "org.openid4java:openid4java",
+]
+
+[
+    maven.amend_artifact(
+        name = "external_deps",
+        coordinates = coord,
+        exclusions = ["com.google.inject:guice"],
+    )
+    for coord in _GUICE_FAT_JAR_CONSUMERS
+]
+
 # Empty maven.install call to apply the configuration to the installation from
 # toml files.
 maven.install(
diff --git a/tools/jgit-ee8/BUILD b/tools/jgit-ee8/BUILD
new file mode 100644
index 0000000..fa80f5c
--- /dev/null
+++ b/tools/jgit-ee8/BUILD
@@ -0,0 +1,21 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+
+# Guard: //:headless must not depend on the canonical Jakarta JGit
+# servlet jar. //lib:jgit-servlet exports the EE8 bridge; both jars
+# share class FQNs and cannot co-exist on a classpath.
+
+genquery(
+    name = "original_jgit_servlet_path",
+    expression = "somepath(//:headless, @jgit//org.eclipse.jgit.http.server:jgit-servlet)",
+    scope = [
+        "//:headless",
+        "@jgit//org.eclipse.jgit.http.server:jgit-servlet",
+    ],
+)
+
+sh_test(
+    name = "classpath_duplicates_test",
+    srcs = ["classpath_duplicates_test.sh"],
+    args = ["$(location :original_jgit_servlet_path)"],
+    data = [":original_jgit_servlet_path"],
+)
diff --git a/tools/jgit-ee8/classpath_duplicates_test.sh b/tools/jgit-ee8/classpath_duplicates_test.sh
new file mode 100755
index 0000000..8c891a3
--- /dev/null
+++ b/tools/jgit-ee8/classpath_duplicates_test.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2026 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.
+
+set -euo pipefail
+
+for query_output in "$@"; do
+  if [[ -s "${query_output}" ]]; then
+    echo "Unexpected path to original JGit servlet-facing target:" >&2
+    cat "${query_output}" >&2
+    exit 1
+  fi
+done
diff --git a/tools/nongoogle.toml b/tools/nongoogle.toml
index caecf6c..1f2e24a 100644
--- a/tools/nongoogle.toml
+++ b/tools/nongoogle.toml
@@ -3,8 +3,8 @@
 
 [versions]
 autoCommon = "1.2.2"
-autoFactory = "1.0.1"
-autoValue= "1.11.0"
+autoFactory = "1.1.0"
+autoValue= "1.11.1"
 flogger = "0.8"
 # Also update the GUAVA version in java/com/google/gerrit/extensions/BUILD
 # to keep Bazel and extension dependencies in sync.
@@ -12,7 +12,7 @@
 guice = "6.0.0"
 lucene = "10.4.0"
 slf4j = "2.0.18"
-sshd = "2.18.0"
+sshd = "2.19.0"
 truth = "1.4.4"
 
 [libraries]
@@ -32,7 +32,7 @@
 guava = { module = "com.google.guava:guava", version.ref = "guava" }
 guava-testlib = { module = "com.google.guava:guava-testlib", version.ref = "guava" }
 guice-assistedinject = { module = "com.google.inject.extensions:guice-assistedinject", version.ref = "guice" }
-guice-library = { module = "com.google.inject:guice", version.ref = "guice" }
+guice-library = { module = "com.google.inject:guice", version.ref = "guice", classifier = "classes" }
 guice-servlet = { module = "com.google.inject.extensions:guice-servlet", version.ref = "guice" }
 h2 = { module = "com.h2database:h2", version = "2.4.240" }
 hamcrest = { module = "org.hamcrest:hamcrest", version = "3.0" }
@@ -49,7 +49,7 @@
 lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" }
 lucene-misc = { module = "org.apache.lucene:lucene-misc", version.ref = "lucene" }
 lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" }
-mina-core = { module = "org.apache.mina:mina-core", version = "2.2.4" }
+mina-core = { module = "org.apache.mina:mina-core", version = "2.2.9" }
 nekohtml = { module = "net.sourceforge.nekohtml:nekohtml", version = "1.9.10" }
 openid-consumer = { module = "org.openid4java:openid4java", version = "1.0.0" }
 protobuf-java = { module = "com.google.protobuf:protobuf-java", version = "4.35.0" }