Merge "Add REST API for migrating label functions to submit requirements (Reland)"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index a603c42..c3cc5f3 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -5536,6 +5536,16 @@
 +
 By default 25.
 
+[[performance.slowRequestThreshold]]performance.slowRequestThreshold::
++
+Threshold from which on a request is considered as "slow" if exceeded.
++
+For slow requests a performance summary is logged on warning level (only if
+performance logging is enabled (see
+link:#tracing.performanceLogging[tracing.performanceLogging] setting).
++
+By default 30s.
+
 [[performance.metric]]
 ==== Subsection performance.metric
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 16dab80..4c60d37 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -114,8 +114,7 @@
     },
     "some-other-project": {
       "id": "some-other-project",
-       "description": "Description of some other project."
-      }
+      "description": "Description of some other project."
     },
   }
 ----
@@ -140,7 +139,7 @@
   {
     "some-project": {
       "id": "some-project",
-      _more_projects: true
+      "_more_projects": true
     }
   }
 ----
diff --git a/WORKSPACE b/WORKSPACE
index 3d32937..666137b 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -108,8 +108,19 @@
 
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
+NODE_20_REPO = {
+    "20.19.5-darwin_arm64": ("node-v20.19.5-darwin-arm64.tar.gz", "node-v20.19.5-darwin-arm64", "cfed7503d8d99fbcf2f52e408ec52f616058eb0867b34dbc3437259993ef5cba"),
+    "20.19.5-darwin_amd64": ("node-v20.19.5-darwin-x64.tar.gz", "node-v20.19.5-darwin-x64", "f9cff058f2766d4d0631dc69b5f7f27664b3a42ff186e25ac7e1ac269af7e696"),
+    "20.19.5-linux_arm64": ("node-v20.19.5-linux-arm64.tar.xz", "node-v20.19.5-linux-arm64", "d462267863ae8ee556039ebdf559055a8ec562c633889ef1403f3adb449ba1dd"),
+    "20.19.5-linux_ppc64le": ("node-v20.19.5-linux-ppc64le.tar.xz", "node-v20.19.5-linux-ppc64le", "ef98025e71d6d498476a95f144e353be074b24431b22eaa81bc64f921ea7d57f"),
+    "20.19.5-linux_s390x": ("node-v20.19.5-linux-s390x.tar.xz", "node-v20.19.5-linux-s390x", "a2e56c4b7fbffd0e6eef3a89e1c5945962fe85b4e2acfa59edc77a9238cc7901"),
+    "20.19.5-linux_amd64": ("node-v20.19.5-linux-x64.tar.xz", "node-v20.19.5-linux-x64", "315046739a513a70e03a4a55a8afda8cf979f30852e576075c340084e3f8ac0f"),
+    "20.19.5-windows_amd64": ("node-v20.19.5-win-x64.zip", "node-v20.19.5-win-x64", "c48159529572a5a947eef2d55d6485dfdc4ce8e67216402e2f6de52ad5d95695"),
+}
+
 node_repositories(
-    node_version = "20.14.0",
+    node_repositories = NODE_20_REPO,
+    node_version = "20.19.5",
     yarn_version = "1.22.19",
 )
 
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index f2bdc29..703e340 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -17,6 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static java.util.Comparator.comparing;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -24,10 +26,12 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.cancellation.PerformanceSummaryProvider;
+import com.google.gerrit.server.util.time.TimeUtil;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -62,6 +66,11 @@
   /** Maximum number of operations for which the latency should be logged. */
   private final int maxOperationsToLog;
 
+  /** Threshold in nanos from which on a request is considered as "slow" if exceeded. */
+  private final long slowRequestThresholdNanos;
+
+  private long startNanos;
+
   public PerformanceLogContext(
       Config gerritConfig, DynamicSet<PerformanceLogger> performanceLoggers) {
     this.performanceLoggers = performanceLoggers;
@@ -76,6 +85,15 @@
 
     this.maxOperationsToLog =
         gerritConfig.getInt("performance", "maxOperationsToLog", DEFAULT_MAX_OPERATIONS_TO_LOG);
+
+    this.slowRequestThresholdNanos =
+        gerritConfig.getTimeUnit(
+            "performance",
+            /* subsection= */ null,
+            "slowRequestThreshold",
+            SECONDS.toNanos(30),
+            NANOSECONDS);
+    this.startNanos = TimeUtil.nowNanos();
   }
 
   @Override
@@ -84,12 +102,23 @@
       runEach(performanceLoggers, LoggingContext.getInstance().getPerformanceLogRecords());
     }
 
-    logger.atFine().log(
+    long elapsedNanos = TimeUtil.nowNanos() - startNanos;
+    logger.at(elapsedNanos > slowRequestThresholdNanos ? Level.WARNING : Level.FINE).log(
         "%s",
         lazy(
             () ->
                 getPerformanceSummary()
-                    .map(performanceSummary -> "[Performance Summary]\n\n" + performanceSummary)
+                    .map(
+                        performanceSummary ->
+                            String.format(
+                                "%s\n\n%s",
+                                elapsedNanos > slowRequestThresholdNanos
+                                    ? String.format(
+                                        "Performance Summary for slow request (request took longer"
+                                            + " than %ss)",
+                                        NANOSECONDS.toSeconds(slowRequestThresholdNanos))
+                                    : "[Performance Summary]",
+                                performanceSummary))
                     .orElse("No performance summary available")));
 
     // Restore old state. Required to support nesting of PerformanceLogContext's.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 7d0d70e..6085900 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -23,7 +23,6 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.MockitoAnnotations.initMocks;
@@ -76,12 +75,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.restapi.change.OnPostReview;
-import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -101,7 +98,6 @@
 /** Tests for comment validation in {@link PostReview}. */
 public class PostReviewIT extends AbstractDaemonTest {
 
-  @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
@@ -127,9 +123,13 @@
           COMMENT_TEXT,
           COMMENT_TEXT.length());
 
+  private static CommentValidator mockCommentValidator;
   @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor;
   @Captor private ArgumentCaptor<CommentValidationContext> captorCtx;
 
+  @SuppressWarnings("unused")
+  private AutoCloseable mockCommentValidatorPlugin;
+
   private static final Correspondence<CommentForValidation, CommentForValidation>
       COMMENT_CORRESPONDENCE =
           Correspondence.from(
@@ -141,28 +141,23 @@
                       && left.getText().equals(right.getText()),
               "matches (ignoring size approximation)");
 
-  @Override
-  public Module createModule() {
-    return new FactoryModule() {
-      @Override
-      public void configure() {
-        CommentValidator mockCommentValidator = mock(CommentValidator.class);
-
-        // by default return no validation errors
-        when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
-
-        bind(CommentValidator.class)
-            .annotatedWith(Exports.named(mockCommentValidator.getClass()))
-            .toInstance(mockCommentValidator);
-        bind(CommentValidator.class).toInstance(mockCommentValidator);
-      }
-    };
+  public static class MockCommentValidatorModule extends FactoryModule {
+    @Override
+    public void configure() {
+      // by default return no validation errors
+      bind(CommentValidator.class)
+          .annotatedWith(Exports.named(mockCommentValidator.getClass()))
+          .toInstance(mockCommentValidator);
+      bind(CommentValidator.class).toInstance(mockCommentValidator);
+    }
   }
 
   @Before
-  public void resetMock() {
+  public void resetMockCommentValidator() throws Exception {
+    mockCommentValidator = mock(CommentValidator.class);
     initMocks(this);
-    clearInvocations(mockCommentValidator);
+    mockCommentValidatorPlugin = super.installPlugin("validator", MockCommentValidatorModule.class);
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
   }
 
   @Test
diff --git a/plugins/download-commands b/plugins/download-commands
index 04a31c1..dc2b671 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 04a31c1a520eac9d1844acc52665d0892bc4e35f
+Subproject commit dc2b67185694c220a95525b6aeb717e663075e3c
diff --git a/polygerrit-ui/app/api/ai-code-review.ts b/polygerrit-ui/app/api/ai-code-review.ts
new file mode 100644
index 0000000..c3148a6
--- /dev/null
+++ b/polygerrit-ui/app/api/ai-code-review.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ChangeInfo} from './rest-api';
+
+export declare interface AiCodeReviewPluginApi {
+  /**
+   * Must only be called once. You cannot register twice (throws an error).
+   * You cannot unregister.
+   */
+  register(provider: AiCodeReviewProvider): void;
+}
+
+export declare interface ChatRequest {
+  /**
+   * The prompt to be sent to the LLM.
+   */
+  prompt: string;
+  /**
+   * UUID of the conversation. To start a new conversation, the caller should
+   * generate a new UUID.
+   * To continue an existing conversation, the caller should provide the UUID of
+   * the conversation.
+   */
+  conversationId: string;
+  /**
+   * Plugins can choose what context they want to derive from the change and
+   * send along to their backends. `changeInfo` contains broadly all the
+   * information about the change, and the plugin can also make additional
+   * requests to the REST API (e.g. getting patch content) by using properties
+   * from the change info.
+   */
+  changeInfo: ChangeInfo;
+}
+
+/**
+ * The chat response may come in as a stream, so instead of just one response
+ * object this listener will get multiple calls until the response is completed.
+ */
+export declare interface ChatResponseListener {
+  /**
+   * Emits one piece of a streaming text response from the backend, to be
+   * interpreted as markdown by the web app and to be shown as is to the user.
+   */
+  emitText(text: string): void;
+  /**
+   * Emits an error message, indicating that the turn has failed. Will be
+   * immediately followed by a done() call.
+   */
+  emitError(error: string): void;
+  /**
+   * The turn is completed. The listener can be discarded.
+   */
+  done(): void;
+}
+
+export declare interface AiCodeReviewProvider {
+  /**
+   * If a AiCodeReviewProvider provider is registered that implements this
+   * method, then Gerrit will offer a side panel for the user to have an AI
+   * Chat conversation. Each chat() call is one turn of such a conversation.
+   */
+  chat?(req: ChatRequest, listener: ChatResponseListener): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 6f6dfb6..934c87cd 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -18,6 +18,7 @@
 import {StylePluginApi} from './styles';
 import {SuggestionsPluginApi} from './suggestions';
 import {ChangeUpdatesPluginApi} from './change-updates';
+import {AiCodeReviewPluginApi} from './ai-code-review';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -65,6 +66,7 @@
    */
   url(): string;
   admin(): AdminPluginApi;
+  aiCodeReview(): AiCodeReviewPluginApi;
   annotationApi(): AnnotationPluginApi;
   attributeHelper(element: Element): AttributeHelperPluginApi;
   changeActions(): ChangeActionsPluginApi;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index d4df7db..ef96c2e 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -45,6 +45,7 @@
 } from '../../../types/events';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {MdSwitch} from '@material/web/switch/switch';
+import {repeat} from 'lit/directives/repeat.js';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -246,7 +247,9 @@
           </div>
           <!-- end header -->
           <div class="rules">
-            ${this.rules?.map(
+            ${repeat(
+              this.rules ?? [],
+              rule => rule.id,
               (rule, index) => html`
                 <gr-rule-editor
                   .hasRange=${this.computeHasRange(this.name)}
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 12e2715..54c1469 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
@@ -121,6 +121,7 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {when} from 'lit/directives/when.js';
 import {ValidationOptionInfo} from '../../../api/rest-api';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -180,6 +181,13 @@
   method: HttpMethod.POST,
 };
 
+const AI_CHAT_ACTION: UIActionInfo = {
+  __key: 'chat',
+  __type: ActionType.CHANGE,
+  enabled: true,
+  label: 'AI Chat',
+};
+
 function isQuickApproveAction(
   action: UIActionInfo
 ): action is QuickApproveUIActionInfo {
@@ -268,6 +276,7 @@
   [ChangeActions.REVERT, {icon: 'undo'}],
   [ChangeActions.STOP_EDIT, {icon: 'stop', filled: true}],
   [QUICK_APPROVE_ACTION.key, {icon: 'check'}],
+  [AI_CHAT_ACTION.__key, {icon: 'stars'}],
   [RevisionActions.SUBMIT, {icon: 'done_all'}],
 ]);
 
@@ -487,12 +496,16 @@
 
   @state() pluginsLoaded = false;
 
+  @state() aiPluginsRegistered = false;
+
   @state() threadsWithUnappliedSuggestions?: CommentThread[];
 
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly flagService = getAppContext().flagsService;
+
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -576,6 +589,11 @@
     );
     subscribe(
       this,
+      () => this.getPluginLoader().pluginsModel.aiCodeReviewPlugins$,
+      plugins => (this.aiPluginsRegistered = (plugins.length ?? 0) > 0)
+    );
+    subscribe(
+      this,
       () => this.getConfigModel().repoConfig$,
       config => (this.privateByDefault = config?.private_by_default)
     );
@@ -1219,6 +1237,16 @@
     this._hideQuickApproveAction = true;
   }
 
+  private getAiChatAction(): UIActionInfo | null {
+    if (!this.flagService.isEnabled(KnownExperimentId.ENABLE_AI_CHAT)) {
+      return null;
+    }
+    if (!this.aiPluginsRegistered) {
+      return null;
+    }
+    return AI_CHAT_ACTION;
+  }
+
   private getQuickApproveAction(): QuickApproveUIActionInfo | null {
     if (this._hideQuickApproveAction) {
       return null;
@@ -1488,6 +1516,10 @@
         this.fireAction(this.prependSlash(key), action, true, action.payload);
         break;
       }
+      case AI_CHAT_ACTION.__key: {
+        fire(this, 'ai-chat', {});
+        break;
+      }
       case ChangeActions.EDIT:
         this.handleEditTap();
         break;
@@ -2200,6 +2232,10 @@
     if (quickApprove) {
       changeActionValues.unshift(quickApprove);
     }
+    const aiChat = this.getAiChatAction();
+    if (aiChat) {
+      changeActionValues.unshift(aiChat);
+    }
 
     return revisionActionValues
       .concat(changeActionValues)
@@ -2327,6 +2363,7 @@
 
 declare global {
   interface HTMLElementEventMap {
+    'ai-chat': CustomEvent<{}>;
     'download-tap': CustomEvent<{}>;
     'edit-tap': CustomEvent<{}>;
     'included-tap': CustomEvent<{}>;
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 37e7ccf..d0e84ac 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
@@ -1185,6 +1185,10 @@
     `;
   }
 
+  private toggleChat() {
+    this.showSidebarChat = !this.showSidebarChat;
+  }
+
   private renderSidebar() {
     if (!this.showSidebarChat) return;
     return html` <div>Hello, I am a fancy sidebar for AI Chat.</div> `;
@@ -1310,6 +1314,7 @@
       <div class="commitActions">
         <gr-change-actions
           id="actions"
+          @ai-chat=${() => this.toggleChat()}
           @edit-tap=${() => this.handleEditTap()}
           @stop-edit-tap=${() => this.handleStopEditTap()}
           @download-tap=${() => this.handleOpenDownloadDialog()}
diff --git a/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts
new file mode 100644
index 0000000..dd7db5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {
+  AiCodeReviewPluginApi,
+  AiCodeReviewProvider,
+} from '../../../api/ai-code-review';
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+}
+
+/**
+ * Plugin API for AI Code Review.
+ *
+ * This object is returned to plugins that want to provide AI Code Review data.
+ * Plugins normally just call register() once at startup and then wait for
+ * calls on the provider interface.
+ */
+export class GrAiCodeReviewApi implements AiCodeReviewPluginApi {
+  private state = State.NOT_REGISTERED;
+
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    readonly plugin: PluginApi
+  ) {
+    this.reporting.trackApi(this.plugin, 'ai-code-review', 'constructor');
+  }
+
+  register(provider: AiCodeReviewProvider): void {
+    this.reporting.trackApi(this.plugin, 'ai-code-review', 'register');
+    if (this.state === State.REGISTERED) {
+      throw new Error('Only one provider can be registered per plugin.');
+    }
+    this.state = State.REGISTERED;
+    this.pluginsModel.aiCodeReviewRegister({
+      pluginName: this.plugin.getPluginName(),
+      provider,
+    });
+  }
+}
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 b61a10a..f87914f 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
@@ -43,8 +43,6 @@
         :host {
           display: block;
           --sidebar-height: calc(100vh - var(--sidebar-top));
-          --sidebar-top: var(--sidebar-top, 0px);
-          --sidebar-bottom-overflow: var(--sidebar-bottom-overflow, 0px);
         }
         .sidebar-wrapper {
           z-index: 50;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 35c93e6..2a46546 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -37,6 +37,7 @@
 import {StylePluginApi} from '../../../api/styles';
 import {GrSuggestionsApi} from '../../plugins/gr-suggestions-api/gr-suggestions-api';
 import {GrChangeUpdatesApi} from '../../plugins/gr-change-updates-api/gr-change-updates-api';
+import {GrAiCodeReviewApi} from '../../plugins/gr-ai-code-review-api/gr-ai-code-review-api';
 
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
@@ -179,6 +180,10 @@
     return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
   }
 
+  aiCodeReview(): GrAiCodeReviewApi {
+    return new GrAiCodeReviewApi(this.report, this.pluginsModel, this);
+  }
+
   annotationApi(): AnnotationPluginApi {
     return new GrAnnotationActionsInterface(
       this.report,
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
index 7a6cbcf..260ac37 100644
--- a/polygerrit-ui/app/embed/gr-textarea.ts
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -4,7 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {css, html, LitElement} from 'lit';
-import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {
+  customElement,
+  property,
+  query,
+  queryAsync,
+  state,
+} from 'lit/decorators.js';
 import {classMap} from 'lit/directives/class-map.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {
@@ -168,6 +174,7 @@
     return this.editableDivElement?.scrollTop ?? 0;
   }
 
+  @state()
   private innerValue: string | undefined;
 
   private innerHint: string | undefined;
@@ -214,7 +221,7 @@
             outline: none;
           }
 
-          &:empty::before {
+          &[data-empty='true']::before {
             content: attr(data-placeholder);
             color: var(--text-secondary, lightgrey);
             display: inline;
@@ -271,6 +278,7 @@
       aria-multiline="true"
       aria-placeholder=${ifDefined(ariaPlaceholder)}
       data-placeholder=${ifDefined(placeholder)}
+      data-empty=${this.innerValue === ''}
       class=${classes}
       contenteditable=${this.contentEditableAttributeValue}
       dir="ltr"
@@ -659,44 +667,16 @@
   private async getValue() {
     const editableDivElement = await this.editableDiv;
     if (editableDivElement) {
-      const [output] = this.parseText(editableDivElement, false, true);
-      return output;
+      // When you delete all text, it leaves a \n (or maybe \r\n?).
+      // Fix this by making it return a empty string.
+      if (/^\r?\n$/.test(editableDivElement.innerText)) {
+        return '';
+      }
+      return editableDivElement.innerText;
     }
     return '';
   }
 
-  private parseText(
-    node: Node,
-    isLastBr: boolean,
-    isFirst: boolean
-  ): [string, boolean] {
-    let textValue = '';
-    let output = '';
-    if (node.nodeName === 'BR') {
-      return ['\n', true];
-    }
-
-    if (node.nodeType === Node.TEXT_NODE && node.textContent) {
-      return [node.textContent, false];
-    }
-
-    if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
-      textValue = '\n';
-    }
-
-    isLastBr = false;
-
-    for (let i = 0; i < node.childNodes?.length; i++) {
-      [output, isLastBr] = this.parseText(
-        node.childNodes[i],
-        isLastBr,
-        i === 0
-      );
-      textValue += output;
-    }
-    return [textValue, isLastBr];
-  }
-
   public getCursorPosition() {
     return this.getCursorPositionForDiv(this.editableDivElement);
   }
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 9df61bc..3ce60da 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -15,6 +15,7 @@
 import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
 import {SuggestionsProvider} from '../../api/suggestions';
 import {ChangeUpdatesPublisher} from '../../api/change-updates';
+import {AiCodeReviewProvider} from '../../api/ai-code-review';
 
 export interface CoveragePlugin {
   pluginName: string;
@@ -32,6 +33,11 @@
   publisher: ChangeUpdatesPublisher;
 }
 
+export interface AiCodeReviewPlugin {
+  pluginName: string;
+  provider: AiCodeReviewProvider;
+}
+
 export interface SuggestionPlugin {
   pluginName: string;
   provider: SuggestionsProvider;
@@ -70,6 +76,11 @@
   checksPlugins: ChecksPlugin[];
 
   /**
+   * List of plugins that have called aiCodeReview().register().
+   */
+  aiCodeReviewPlugins: AiCodeReviewPlugin[];
+
+  /**
    * List of plugins that have called suggestions().register().
    */
   suggestionsPlugins: SuggestionPlugin[];
@@ -105,6 +116,11 @@
     state => state.changeUpdatesPlugins
   );
 
+  public aiCodeReviewPlugins$ = select(
+    this.state$,
+    state => state.aiCodeReviewPlugins
+  );
+
   public suggestionsPlugins$ = select(
     this.state$,
     state => state.suggestionsPlugins
@@ -118,6 +134,7 @@
       coveragePlugins: [],
       changeUpdatesPlugins: [],
       checksPlugins: [],
+      aiCodeReviewPlugins: [],
       suggestionsPlugins: [],
       tokenHighlightPlugins: [],
     });
@@ -175,6 +192,22 @@
     this.setState(nextState);
   }
 
+  aiCodeReviewRegister(plugin: AiCodeReviewPlugin) {
+    const nextState = {...this.getState()};
+    nextState.aiCodeReviewPlugins = [...nextState.aiCodeReviewPlugins];
+    const alreadyRegistered = nextState.aiCodeReviewPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a AI Code Review provider. Ignored.`
+      );
+      return;
+    }
+    nextState.aiCodeReviewPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   suggestionsRegister(plugin: SuggestionPlugin) {
     const nextState = {...this.getState()};
     nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 2c7993c..70d24aa 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,4 +25,5 @@
   ML_SUGGESTED_EDIT_EDITABLE_SUGGESTION = 'UiFeature__ml_suggested_edit_editable_suggestion',
   SHOW_FLOWS_TAB = 'UiFeature__show_flows_tab',
   ASYNC_SUBMIT_REQUIREMENTS = 'UiFeature__async_submit_requirements',
+  ENABLE_AI_CHAT = 'UiFeature__enable_ai_chat',
 }