chromium/chrome/browser/resources/compose/app.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './icons.html.js';
import './strings.m.js';
import './textarea.js';
import './result_text.js';
import '//resources/cr_elements/cr_button/cr_button.js';
import '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import '//resources/cr_elements/cr_icon_button/cr_icon_button.js';
import '//resources/cr_elements/cr_loading_gradient/cr_loading_gradient.js';
import '//resources/cr_elements/cr_shared_vars.css.js';
import '//resources/cr_elements/icons.html.js';
import '//resources/cr_elements/md_select.css.js';
import '//resources/polymer/v3_0/iron-icon/iron-icon.js';

import {ColorChangeUpdater} from '//resources/cr_components/color_change_listener/colors_css_updater.js';
import type {CrA11yAnnouncerElement} from '//resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {getInstance as getAnnouncerInstance} from '//resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import type {CrButtonElement} from '//resources/cr_elements/cr_button/cr_button.js';
import type {CrFeedbackButtonsElement} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {CrFeedbackOption} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {CrScrollObserverMixin} from '//resources/cr_elements/cr_scroll_observer_mixin.js';
import {I18nMixin} from '//resources/cr_elements/i18n_mixin.js';
import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {loadTimeData} from '//resources/js/load_time_data.js';
import {isMac} from '//resources/js/platform.js';
import {Debouncer, microTask, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {ComposeAppAnimator} from './animations/app_animator.js';
import {getTemplate} from './app.html.js';
import type {ComposeResponse, ComposeState, ComposeUntrustedDialogCallbackRouter, ConfigurableParams, PartialComposeResponse} from './compose.mojom-webui.js';
import {CloseReason, StyleModifier, UserFeedback} from './compose.mojom-webui.js';
import type {ComposeApiProxy} from './compose_api_proxy.js';
import {ComposeApiProxyImpl} from './compose_api_proxy.js';
import {ComposeStatus} from './compose_enums.mojom-webui.js';
import type {ComposeResultTextElement, TextInput} from './result_text.js';
import type {ComposeTextareaElement} from './textarea.js';

// Struct with ComposeAppElement's properties that need to be saved to return
// the element to a specific state.
export interface ComposeAppState {
  editedInput?: string;
  input: string;
  isEditingSubmittedInput?: boolean;
  selectedLength?: StyleModifier;
  selectedTone?: StyleModifier;
}

export interface ComposeAppElement {
  $: {
    firstRunDialog: HTMLElement,
    firstRunFooter: HTMLElement,
    firstRunOkButton: CrButtonElement,
    freMsbbDialog: HTMLElement,
    appDialog: HTMLElement,
    body: HTMLElement,
    bodyAndFooter: HTMLElement,
    cancelEditButton: CrButtonElement,
    closeButton: HTMLElement,
    firstRunCloseButton: HTMLElement,
    closeButtonMSBB: HTMLElement,
    editTextarea: ComposeTextareaElement,
    errorFooter: HTMLElement,
    errorGoBackButton: CrButtonElement,
    acceptButton: CrButtonElement,
    loading: HTMLElement,
    undoButton: CrButtonElement,
    undoButtonRefined: CrButtonElement,
    redoButton: CrButtonElement,
    refreshButton: HTMLElement,
    resultContainer: HTMLElement,
    resultTextContainer: HTMLElement,
    resultFooter: HTMLElement,
    submitButton: CrButtonElement,
    submitEditButton: CrButtonElement,
    submitFooter: HTMLElement,
    onDeviceUsedFooter: HTMLElement,
    textarea: ComposeTextareaElement,
    lengthMenu: HTMLSelectElement,
    toneMenu: HTMLSelectElement,
    modifierMenu: HTMLSelectElement,
    resultText: ComposeResultTextElement,
    feedbackButtons: CrFeedbackButtonsElement,
  };
}

/**
 * Delay required for screen readers to read out consecutive messages while
 * focus is being moved between elements.
 */
export const TIMEOUT_MS: number = 700;

const ComposeAppElementBase = I18nMixin(CrScrollObserverMixin(PolymerElement));

// Enumerates trigger points of compose or regenerate calls.
// Used to mark where a compose call was made so focus
// can be restored to the respective element afterwards.
enum TriggerElement {
  SUBMIT_INPUT,  // For initial input or editing input.
  TONE,
  LENGTH,
  MODIFIER,
  REFRESH,
  UNDO,
  REDO,
}

export class ComposeAppElement extends ComposeAppElementBase {
  static get is() {
    return 'compose-app';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      editedInput_: {
        type: String,
        observer: 'onEditedInputChanged_',
      },
      enableAnimations: {
        type: Boolean,
        value: loadTimeData.getBoolean('enableAnimations'),
        reflectToAttribute: true,
      },
      enableUiRefinements: {
        type: Boolean,
        value: loadTimeData.getBoolean('enableRefinedUi'),
        reflectToAttribute: true,
      },
      feedbackState_: {
        type: String,
        value: CrFeedbackOption.UNSPECIFIED,
      },
      input_: {
        type: String,
        observer: 'onInputChanged_',
      },
      isEditingSubmittedInput_: {
        type: Boolean,
        reflectToAttribute: true,
        value: false,
        observer: 'onIsEditingSubmittedInputChanged_',
      },
      isEditingResultText_: {
        type: Boolean,
        reflectToAttribute: true,
        value: false,
      },
      isEditSubmitEnabled_: {
        type: Boolean,
        value: true,
      },
      isSubmitEnabled_: {
        type: Boolean,
        value: true,
      },
      loading_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },
      loadingIndicatorShown_: {
        type: Boolean,
        reflectToAttribute: true,
        computed: 'isLoadingIndicatorShown_(loading_, hasOutput_)',
      },
      response_: {
        type: Object,
        value: null,
      },
      partialResponse_: {
        type: Object,
        value: null,
      },
      selectedLength_: {
        type: Number,
        value: StyleModifier.kUnset,
      },
      selectedTone_: {
        type: Number,
        value: StyleModifier.kUnset,
      },
      showMainAppDialog_: {
        type: Boolean,
        value: false,
      },
      submitted_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },
      undoEnabled_: {
        type: Boolean,
        value: false,
      },
      redoEnabled_: {
        type: Boolean,
        value: false,
      },
      feedbackEnabled_: {
        type: Boolean,
        value: true,
      },
      responseText_: {
        type: String,
        computed: 'getResponseText_(response_, partialResponse_)',
      },
      outputComplete_: {
        type: Boolean,
      },
      hasOutput_: {
        type: Boolean,
      },
      displayedText_: {
        type: String,
      },
      lengthOptions_: {
        type: Array,
        value: () => {
          return [
            {
              value: StyleModifier.kUnset,
              label: loadTimeData.getString('lengthMenuTitle'),
              isDefault: true,
            },
            {
              value: StyleModifier.kShorter,
              label: loadTimeData.getString('shorterOption'),
            },
            {
              value: StyleModifier.kLonger,
              label: loadTimeData.getString('longerOption'),
            },
          ];
        },
      },
      toneOptions_: {
        type: Array,
        value: () => {
          return [
            {
              value: StyleModifier.kUnset,
              label: loadTimeData.getString('toneMenuTitle'),
              isDefault: true,
            },
            {
              value: StyleModifier.kCasual,
              label: loadTimeData.getString('casualToneOption'),
            },
            {
              value: StyleModifier.kFormal,
              label: loadTimeData.getString('formalToneOption'),
            },
          ];
        },
      },
      modifierOptions_: {
        type: Array,
        value: () => {
          return [
            {
              value: StyleModifier.kUnset,
              label: loadTimeData.getString('modifierMenuTitle'),
              isDefault: true,
            },
            {
              value: StyleModifier.kLonger,
              label: loadTimeData.getString('longerOption'),
            },
            {
              value: StyleModifier.kShorter,
              label: loadTimeData.getString('shorterOption'),
            },
            {
              value: StyleModifier.kFormal,
              label: loadTimeData.getString('formalToneOption'),
            },
            {
              value: StyleModifier.kCasual,
              label: loadTimeData.getString('casualToneOption'),
            },
            {
              value: StyleModifier.kRetry,
              label: loadTimeData.getString('retryOption'),
            },
          ];
        },
      },
    };
  }

  static get observers() {
    return [
      'debounceSaveComposeAppState_(input_, isEditingSubmittedInput_, ' +
          'editedInput_)',
      'debounceUpdateResultComplete_(outputComplete_, response_)',
    ];
  }

  enableAnimations: boolean;
  enableUiRefinements: boolean;

  private animator_: ComposeAppAnimator;
  private apiProxy_: ComposeApiProxy = ComposeApiProxyImpl.getInstance();
  private eventTracker_: EventTracker = new EventTracker();
  private router_: ComposeUntrustedDialogCallbackRouter =
      this.apiProxy_.getRouter();
  private showFirstRunDialog_: boolean;
  private showMainAppDialog_: boolean;
  private showMSBBDialog_: boolean;
  private shouldShowMSBBDialog_: boolean;
  private editedInput_: string;
  private feedbackState_: CrFeedbackOption;
  private input_: string;
  private inputParams_: ConfigurableParams;
  private isEditingSubmittedInput_: boolean;
  private isEditingResultText_: boolean;
  private isEditSubmitEnabled_: boolean;
  private isSubmitEnabled_: boolean;
  private loading_: boolean;
  private response_: ComposeResponse|null;
  private partialResponse_: PartialComposeResponse|undefined;
  private saveAppStateDebouncer_: Debouncer;
  private scrollCheckDebouncer_: Debouncer;
  private updateResultCompleteDebouncer_: Debouncer;
  private selectedLength_: StyleModifier;
  private selectedTone_: StyleModifier;
  private textSelected_: boolean;
  private submitted_: boolean;
  private undoEnabled_: boolean;
  private redoEnabled_: boolean;
  private feedbackEnabled_: boolean;
  private userHasModifiedState_: boolean = false;
  private lastTriggerElement_: TriggerElement;
  private outputComplete_: boolean = true;
  private hasOutput_: boolean = false;
  private displayedText_: string;
  private responseText_: string;
  private userResponseText_: string|undefined;

  constructor() {
    super();
    ColorChangeUpdater.forDocument().start();
    this.animator_ = new ComposeAppAnimator(
        this, loadTimeData.getBoolean('enableAnimations'));
    this.enableUiRefinements = loadTimeData.getBoolean('enableRefinedUi');
    this.getInitialState_();
    this.router_.responseReceived.addListener((response: ComposeResponse) => {
      this.composeResponseReceived_(response);
    });
    this.router_.partialResponseReceived.addListener(
        (partialResponse: PartialComposeResponse) => {
          this.partialComposeResponseReceived_(partialResponse);
        });
  }

  // Overridden from CrScrollObserverMixin in order to change the scrolling
  // container based on the UI Refinements flag.
  override getContainer(): HTMLElement {
    return this.enableUiRefinements ? this.$.resultTextContainer : this.$.body;
  }

  private getResponseText_(): TextInput {
    if (this.userResponseText_ !== undefined) {
      return {
        text: this.userResponseText_,
        isPartial: false,
        streamingEnabled: false,
      };
    } else if (this.response_) {
      return {
        text: this.response_.status === ComposeStatus.kOk ?
            this.response_.result.trim() :
            '',
        isPartial: false,
        streamingEnabled: this.partialResponse_ !== undefined,
      };
    } else if (this.partialResponse_) {
      return {
        text: this.partialResponse_?.result.trim(),
        isPartial: true,
        streamingEnabled: true,
      };
    } else {
      return {text: '', isPartial: false, streamingEnabled: false};
    }
  }

  override connectedCallback() {
    super.connectedCallback();
    this.eventTracker_.add(document, 'visibilitychange', () => {
      if (document.visibilityState !== 'visible') {
        // Ensure app state is saved when hiding the dialog.
        this.saveComposeAppState_();
      }
    });
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.eventTracker_.removeAll();
  }

  private debounceSaveComposeAppState_() {
    this.saveAppStateDebouncer_ = Debouncer.debounce(
        this.saveAppStateDebouncer_, microTask,
        () => this.saveComposeAppState_());
  }

  private getInitialState_() {
    this.apiProxy_.requestInitialState().then(initialState => {
      this.inputParams_ = initialState.configurableParams;
      // The dialog can initially be in one of three view states. Completion of
      // the FRE causes the dialog to show the MSBB state if MSBB is not
      // enabled, and the main app state otherwise.
      this.showFirstRunDialog_ = !initialState.freComplete;
      this.showMSBBDialog_ =
          initialState.freComplete && !initialState.msbbState;
      this.shouldShowMSBBDialog_ = !initialState.msbbState;

      this.showMainAppDialog_ =
          initialState.freComplete && initialState.msbbState;

      if (initialState.initialInput) {
        this.input_ = initialState.initialInput;
      }
      this.textSelected_ = initialState.textSelected;
      this.partialResponse_ = undefined;
      const composeState = initialState.composeState;
      this.feedbackState_ = userFeedbackToFeedbackOption(composeState.feedback);
      this.loading_ = composeState.hasPendingRequest;
      this.submitted_ =
          composeState.hasPendingRequest || Boolean(composeState.response);
      if (!composeState.hasPendingRequest) {
        // If there is a pending request, the existing response is outdated.
        this.response_ = composeState.response;
        this.undoEnabled_ = Boolean(this.response_?.undoAvailable);
        this.redoEnabled_ = Boolean(this.response_?.redoAvailable);
        this.feedbackEnabled_ = Boolean(!this.response_?.providedByUser);
      }

      if (composeState.webuiState) {
        const appState: ComposeAppState = JSON.parse(composeState.webuiState);
        this.input_ = appState.input;
        this.selectedLength_ = appState.selectedLength ?? StyleModifier.kUnset;
        this.selectedTone_ = appState.selectedTone ?? StyleModifier.kUnset;
        if (appState.isEditingSubmittedInput) {
          this.isEditingSubmittedInput_ = appState.isEditingSubmittedInput;
          this.editedInput_ = appState.editedInput!;
        }
      }

      if (this.showFirstRunDialog_) {
        this.animator_.transitionToFirstRun();
      } else {
        this.animator_.transitionInDialog();
      }

      // Wait for one timeout to flush Polymer tasks, then wait for the next
      // render.
      setTimeout(() => {
        requestAnimationFrame(() => this.apiProxy_.showUi());
      });
    });
  }

  private getTrimmedResult_(): string|undefined {
    return this.response_?.result.trim();
  }

  private getTrimmedPartialResult_(): string|undefined {
    return this.partialResponse_?.result.trim();
  }

  private onFirstRunOkButtonClick_() {
    this.apiProxy_.completeFirstRun();

    if (this.shouldShowMSBBDialog_) {
      this.showMSBBDialog_ = true;
    } else {
      this.showMainAppDialog_ = true;
      this.animator_.transitionToInput();
    }

    this.showFirstRunDialog_ = false;
  }

  private onFirstRunBottomTextClick_(e: Event) {
    e.preventDefault();
    // Embedded links do not work in WebUI so handle in the parent event
    // listener.
    if ((e.target as HTMLElement).tagName === 'A') {
      this.apiProxy_.openComposeLearnMorePage();
    }
  }

  private onCancelEditClick_() {
    const fullBodyHeight = this.$.body.offsetHeight;
    const resultContainerHeight = this.$.resultContainer.offsetHeight;
    this.isEditingSubmittedInput_ = false;
    this.$.textarea.focusEditButton();
    this.animator_.transitionFromEditingToResult(resultContainerHeight);
    this.$.textarea.transitionToResult(fullBodyHeight);
    this.$.editTextarea.transitionToResult(fullBodyHeight);

    this.apiProxy_.logCancelEdit();
  }

  private onClose_(e: Event) {
    switch ((e.target as HTMLElement).id) {
      case 'firstRunCloseButton': {
        this.apiProxy_.closeUi(CloseReason.kFirstRunCloseButton);
        break;
      }
      case 'closeButtonMSBB': {
        this.apiProxy_.closeUi(CloseReason.kMSBBCloseButton);
        break;
      }
      case 'closeButton': {
        this.apiProxy_.closeUi(CloseReason.kCloseButton);
        break;
      }
    }
  }

  private onEditedInputChanged_() {
    this.userHasModifiedState_ = true;
    if (!this.isEditSubmitEnabled_) {
      this.isEditSubmitEnabled_ = this.$.editTextarea.validate();
    }
  }

  private onEditClick_() {
    const fullBodyHeight = this.$.body.offsetHeight;
    const resultContainerHeight = this.$.resultContainer.offsetHeight;
    this.editedInput_ = this.input_;
    this.isEditingSubmittedInput_ = true;
    this.animator_.transitionFromResultToEditing(resultContainerHeight);
    this.$.textarea.transitionToEditing(fullBodyHeight);
    this.$.editTextarea.transitionToEditing(fullBodyHeight);

    this.apiProxy_.logEditInput();
  }

  private onIsEditingSubmittedInputChanged_() {
    if (this.isEditingSubmittedInput_) {
      // When switching to editing the submitted input, manually move focus
      // to the input.
      this.$.editTextarea.focusInput();
    }
  }

  private onRefresh_() {
    this.rewrite_(StyleModifier.kRetry);
    this.lastTriggerElement_ = TriggerElement.REFRESH;
  }

  private onSubmit_() {
    this.isSubmitEnabled_ = this.$.textarea.validate();
    if (!this.isSubmitEnabled_) {
      this.$.textarea.focusInput();
      return;
    }

    this.$.textarea.scrollInputToTop();
    const bodyHeight = this.$.body.offsetHeight;
    const footerHeight = this.$.submitFooter.offsetHeight;
    this.submitted_ = true;
    this.animator_.transitionOutSubmitFooter(bodyHeight, footerHeight);
    this.$.textarea.transitionToReadonly();
    this.compose_();
    this.lastTriggerElement_ = TriggerElement.SUBMIT_INPUT;
  }

  private onSubmitEdit_() {
    this.isEditSubmitEnabled_ = this.$.editTextarea.validate();
    if (!this.isEditSubmitEnabled_) {
      this.$.editTextarea.focusInput();
      return;
    }

    const bodyHeight = this.$.bodyAndFooter.offsetHeight;
    const editTextareaHeight = this.$.editTextarea.offsetHeight;
    this.isEditingSubmittedInput_ = false;
    this.input_ = this.editedInput_;
    this.selectedLength_ = StyleModifier.kUnset;
    this.selectedTone_ = StyleModifier.kUnset;
    this.animator_.transitionFromEditingToLoading(bodyHeight);
    this.$.textarea.transitionToReadonly(editTextareaHeight);
    this.$.editTextarea.transitionToReadonly(editTextareaHeight);
    this.compose_(true);
    this.lastTriggerElement_ = TriggerElement.SUBMIT_INPUT;
  }

  private onAccept_() {
    this.apiProxy_.acceptComposeResult().then((success: boolean) => {
      if (success) {
        this.apiProxy_.closeUi(CloseReason.kInsertButton);
      }
    });
  }

  private onInputChanged_() {
    this.userHasModifiedState_ = true;
    if (!this.isSubmitEnabled_) {
      this.isSubmitEnabled_ = this.$.textarea.validate();
    }
  }

  private onLengthChanged_() {
    this.selectedLength_ = Number(this.$.lengthMenu.value) as StyleModifier;
    this.rewrite_(this.selectedLength_);
    this.lastTriggerElement_ = TriggerElement.LENGTH;
  }

  private onToneChanged_() {
    this.selectedTone_ = Number(this.$.toneMenu.value) as StyleModifier;
    this.rewrite_(this.selectedTone_);
    this.lastTriggerElement_ = TriggerElement.TONE;
  }

  private onModifierChanged_() {
    const selectedModifier =
      Number(this.$.modifierMenu.value) as StyleModifier;
    this.rewrite_(selectedModifier);
    this.lastTriggerElement_ = TriggerElement.MODIFIER;
    // Immediately clear the selection after triggering a rewrite. A selected
    // index of 0 corresponds to the default value, which is disabled and cannot
    // be selected in the dialog.
    this.$.modifierMenu.selectedIndex = 0;
  }

  private openModifierMenuOnKeyDown_(e: KeyboardEvent) {
    // On Windows and Linux, ArrowDown and ArrowUp key events directly change
    // the menu selection, which fires the `select` on-change event without
    // showing what selection was made.
    // MacOS keyboard controls opens the dropdown menu on ArrowUp/Down and thus
    // does not need to override behaviour.
    if (isMac) {
      return;
    }

    // Override keyboard controls for ArrowUp/Down to open the `select` menu.
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      this.$.modifierMenu.showPicker();
    }
  }

  private onFooterClick_(e: Event) {
    if ((e.target as HTMLElement).tagName !== 'A') {
      // Do nothing if a link is not clicked.
      return;
    }
    e.preventDefault();
    // The "File a bug", "survey", and "sign in" links are embedded into the
    // string. Embedded links do not work in WebUI so handle each click in the
    // parent event listener.
    switch ((e.target as HTMLElement).id) {
      case 'bugLink':
        this.apiProxy_.openBugReportingLink();
        break;
      case 'surveyLink':
        this.apiProxy_.openFeedbackSurveyLink();
        break;
      case 'signInLink':
        this.apiProxy_.openSignInPage();
        break;
      default:
        this.apiProxy_.openComposeLearnMorePage();
    }
  }

  private onMsbbSettingsClick_(e: Event) {
    e.preventDefault();
    // Instruct the browser to open the corresponding settings page.
    this.apiProxy_.openComposeSettings();
  }

  private compose_(inputEdited: boolean = false) {
    assert(this.$.textarea.validate());
    assert(this.submitted_);
    // <if expr="is_macosx">
    // For VoiceOver, the screen reader on Mac, to read consecutive alerts the
    // contents must change between announcements. To satisfy this, new results
    // are announced by alternating between this "loading" message and the
    // "updated" message. This is also done to announce updates for the undo
    // and redo functions.
    this.screenReaderAnnounce_(this.i18n('resultLoadingA11yMessage'));
    // </if>
    this.$.body.scrollTop = 0;
    this.loading_ = true;
    this.animator_.transitionInLoading();
    this.userResponseText_ = undefined;
    this.response_ = null;
    this.partialResponse_ = undefined;
    this.feedbackEnabled_ = true;
    this.saveComposeAppState_();  // Ensure state is saved before compose call.
    this.apiProxy_.compose(this.input_, inputEdited);
  }

  private rewrite_(style: StyleModifier) {
    assert(this.$.textarea.validate());
    assert(this.submitted_);
    // <if expr="is_macosx">
    this.screenReaderAnnounce_(this.i18n('resultLoadingA11yMessage'));
    // </if>
    const bodyHeight = this.$.body.offsetHeight;
    const resultHeight = this.$.resultContainer.offsetHeight;
    this.$.body.scrollTop = 0;
    this.loading_ = true;
    this.userResponseText_ = undefined;
    this.response_ = null;
    this.partialResponse_ = undefined;
    this.feedbackEnabled_ = true;
    this.saveComposeAppState_();  // Ensure state is saved before compose call.
    this.apiProxy_.rewrite(style);
    this.animator_.transitionFromResultToLoading(bodyHeight, resultHeight);
  }

  private debounceUpdateResultComplete_() {
    this.updateResultCompleteDebouncer_ = Debouncer.debounce(
        this.updateResultCompleteDebouncer_, microTask, () => {
          return this.updateResultComplete_();
        });
  }

  private updateResultComplete_() {
    if (!this.response_) {
      return;
    }
    if (this.response_.status === ComposeStatus.kOk) {
      // Don't process OK status until outputComplete_ is true.
      if (!this.outputComplete_) {
        return;
      }
    }

    this.userResponseText_ = undefined;
    const loadingHeight = this.$.loading.offsetHeight;
    this.loading_ = false;
    this.undoEnabled_ = this.response_.undoAvailable;
    this.$.textarea.transitionToEditable();
    if (!this.partialResponse_) {
      if (this.response_.status === ComposeStatus.kOk) {
        this.animator_.transitionFromLoadingToCompleteResult(loadingHeight);
      }
    } else {
      if (this.outputComplete_ && this.response_.status === ComposeStatus.kOk) {
        this.animator_.transitionFromPartialToCompleteResult();
      }
    }

    switch (this.lastTriggerElement_) {
      case TriggerElement.SUBMIT_INPUT:
        this.$.textarea.focusEditButton();
        break;
      case TriggerElement.REFRESH:
        this.$.refreshButton.focus({preventScroll: true});
        break;
      case TriggerElement.LENGTH:
        this.$.lengthMenu.focus({preventScroll: true});
        break;
      case TriggerElement.TONE:
        this.$.toneMenu.focus({preventScroll: true});
        break;
      case TriggerElement.MODIFIER:
        this.$.modifierMenu.focus({ preventScroll: true });
        break;
      case TriggerElement.UNDO:
        if (this.enableUiRefinements) {
          this.$.undoButtonRefined.focus();
        } else {
          this.$.undoButton.focus();
        }
        break;
      case TriggerElement.REDO:
        this.$.redoButton.focus();
        break;
    }

    this.screenReaderAnnounce_(
        this.i18n('resultUpdatedA11yMessage'), TIMEOUT_MS);
  }

  private composeResponseReceived_(response: ComposeResponse) {
    this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
    this.response_ = response;
    this.redoEnabled_ = false;
    this.feedbackEnabled_ = true;
  }

  private partialComposeResponseReceived_(partialResponse:
                                              PartialComposeResponse) {
    assert(!this.response_);
    this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
    this.partialResponse_ = partialResponse;
  }

  private isLoadingIndicatorShown_(): boolean {
    return this.loading_ && !this.hasOutput_;
  }

  // Elements related to results should be hidden when the output is empty, but
  // not if the results are in an edited state. The latter corresponds with
  // feedback being disabled.
  private hideResults_(): boolean {
    return !this.hasOutput_ && this.feedbackEnabled_;
  }

  private hasSuccessfulResponse_(): boolean {
    return this.response_?.status === ComposeStatus.kOk;
  }

  private hasPartialResponse_(): boolean {
    return Boolean(this.partialResponse_);
  }

  private hasPartialOrCompleteResponse_(): boolean {
    return Boolean(this.partialResponse_) || this.hasSuccessfulResponse_();
  }

  private hasFailedResponse_(): boolean {
    if (!this.response_) {
      return false;
    }

    return this.response_.status !== ComposeStatus.kOk;
  }

  private hasErrorWithLink_(): boolean {
    return this.hasUnsupportedLanguageResponse_() ||
        this.hasPermissionDeniedResponse_();
  }

  private hasUnsupportedLanguageResponse_(): boolean {
    if (!this.response_) {
      return false;
    }

    return this.response_.status === ComposeStatus.kUnsupportedLanguage;
  }

  private hasPermissionDeniedResponse_(): boolean {
    if (!this.response_) {
      return false;
    }

    return this.response_.status === ComposeStatus.kPermissionDenied;
  }

  private onDeviceEvaluationUsed_(): boolean {
    return Boolean(this.response_?.onDeviceEvaluationUsed);
  }

  private showOnDeviceDogfoodFooter_(): boolean {
    return Boolean(this.response_?.onDeviceEvaluationUsed) &&
        loadTimeData.getBoolean('enableOnDeviceDogfoodFooter');
  }

  private undoButtonIcon_(): string {
    return this.enableUiRefinements ? 'compose:undo' : 'compose:mvpUndo';
  }

  private acceptButtonText_(): string {
    return this.textSelected_ ? this.i18n('replaceButton') :
                                this.i18n('insertButton');
  }

  private failedResponseErrorText_(): string {
    switch (this.response_?.status) {
      case ComposeStatus.kFiltered:
        return this.i18n('errorFiltered');
      case ComposeStatus.kRequestThrottled:
        return this.i18n('errorRequestThrottled');
      case ComposeStatus.kOffline:
        return this.i18n('errorOffline');
      case ComposeStatus.kRequestTimeout:
        return this.i18n('errorTryAgainLater');
      case ComposeStatus.kClientError:
      case ComposeStatus.kMisconfiguration:
      case ComposeStatus.kServerError:
      case ComposeStatus.kInvalidRequest:
      case ComposeStatus.kRetryableError:
      case ComposeStatus.kNonRetryableError:
      case ComposeStatus.kDisabled:
      case ComposeStatus.kCancelled:
      case ComposeStatus.kNoResponse:
      default:
        return this.i18n('errorTryAgain');
    }
  }

  private isBackFromErrorAvailable_(): boolean {
    // True when the current response is a filtering error and resulted from
    // applying a modifier.
    return Boolean(
        this.response_?.status === ComposeStatus.kFiltered &&
        this.response_?.triggeredFromModifier);
  }

  private onResultEdit_(e: CustomEvent<string>) {
    this.userResponseText_ = e.detail;
    this.apiProxy_.editResult(this.userResponseText_).then(isEdited => {
      if (isEdited) {
        this.undoEnabled_ = true;
        this.redoEnabled_ = false;
        this.feedbackEnabled_ = false;
        this.feedbackState_ = CrFeedbackOption.UNSPECIFIED;
      }
    });
  }

  private onSetResultFocus_(e: CustomEvent<boolean>) {
    this.isEditingResultText_ = e.detail;
  }

  private saveComposeAppState_() {
    if (this.saveAppStateDebouncer_?.isActive()) {
      this.saveAppStateDebouncer_.flush();
      return;
    }

    if (!this.userHasModifiedState_) {
      return;
    }

    const state: ComposeAppState = {input: this.input_};
    if (this.selectedLength_ !== StyleModifier.kUnset) {
      state.selectedLength = this.selectedLength_;
    }
    if (this.selectedTone_ !== StyleModifier.kUnset) {
      state.selectedTone = this.selectedTone_;
    }
    if (this.isEditingSubmittedInput_) {
      state.isEditingSubmittedInput = this.isEditingSubmittedInput_;
      state.editedInput = this.editedInput_;
    }
    this.apiProxy_.saveWebuiState(JSON.stringify(state));
  }

  private async onUndoClick_() {
    // <if expr="is_macosx">
    this.screenReaderAnnounce_(this.i18n('undoResultA11yMessage'));
    // </if>
    try {
      const state = await this.apiProxy_.undo();
      if (state == null) {
        // Attempted to undo when there are no compose states available to undo.
        // Ensure undo is disabled since it is not possible.
        this.undoEnabled_ = false;
        return;
      }
      this.updateWithNewState_(state);
      // If UI Refinements is enabled, then focus is moved from the undo button
      // to the redo button if undo is disabled in the new state. Otherwise, the
      // undo button always keeps focus.
      if (this.undoEnabled_ || !this.enableUiRefinements) {
        this.lastTriggerElement_ = TriggerElement.UNDO;
      } else {
        this.lastTriggerElement_ = TriggerElement.REDO;
      }
    } catch (error) {
      // Error (e.g., disconnected mojo pipe) from a rejected Promise. Allow the
      // user to try again as there should be a valid state to restore.
      // TODO(b/301368162): Ask UX how to handle the edge case of multiple
      // fails.
    }
  }

  private async onErrorGoBackButton_() {
    try {
      const state = await this.apiProxy_.recoverFromErrorState();
      // This button should only be enabled following application of a modifier,
      // which ensures a previous state to revert to.
      assert(state);

      this.updateWithNewState_(state);
    } catch (error) {
      // Error (e.g., disconnected mojo pipe) from a rejected Promise. Allow the
      // user to try again as there should be a valid state to restore.
      // TODO(b/301368162): Ask UX how to handle the edge case of multiple
      // fails.
    }
  }

  private async onRedoClick_() {
    // <if expr="is_macosx">
    this.screenReaderAnnounce_(this.i18n('redoResultA11yMessage'));
    // </if>
    try {
      const state = await this.apiProxy_.redo();
      if (state == null) {
        // Attempted to redo when there are no compose states available to redo.
        // Ensure redo is disabled since it is not possible.
        this.redoEnabled_ = false;
        return;
      }

      this.updateWithNewState_(state);
      // If redo is disabled, then give focus to the undo button by default.
      if (this.redoEnabled_) {
        this.lastTriggerElement_ = TriggerElement.REDO;
      } else {
        this.lastTriggerElement_ = TriggerElement.UNDO;
      }
    } catch (error) {
      // Error (e.g., disconnected mojo pipe) from a rejected Promise. Allow the
      // user to try again as there should be a valid state to restore.
      // TODO(b/301368162): Ask UX how to handle the edge case of multiple
      // fails.
    }
  }

  private updateWithNewState_(state: ComposeState) {
    // Restore the dialog to the given state.
    this.feedbackEnabled_ = !(state.response?.providedByUser);
    this.userResponseText_ =
        this.feedbackEnabled_ ? undefined : state.response?.result;
    this.response_ = state.response;
    this.partialResponse_ = undefined;
    this.undoEnabled_ = Boolean(state.response?.undoAvailable);
    this.redoEnabled_ = Boolean(state.response?.redoAvailable);
    this.feedbackState_ = userFeedbackToFeedbackOption(state.feedback);
    if (state.webuiState) {
      const appState: ComposeAppState = JSON.parse(state.webuiState);
      this.input_ = appState.input;
      // TODO(b/333985071): Remove modifier tracking when ComposeUiRefinement
      // flag is removed.
      this.selectedLength_ = appState.selectedLength ?? StyleModifier.kUnset;
      this.selectedTone_ = appState.selectedTone ?? StyleModifier.kUnset;
    }
  }

  private screenReaderAnnounce_(message: string, wait: number = 0) {
    setTimeout(() => {
      const announcer = getAnnouncerInstance() as CrA11yAnnouncerElement;
      announcer.announce(message, wait);
    });
  }

  private onFeedbackSelectedOptionChanged_(
      e: CustomEvent<{value: CrFeedbackOption}>) {
    this.feedbackState_ = e.detail.value;
    switch (e.detail.value) {
      case CrFeedbackOption.UNSPECIFIED:
        this.apiProxy_.setUserFeedback(UserFeedback.kUserFeedbackUnspecified);
        return;
      case CrFeedbackOption.THUMBS_UP:
        this.apiProxy_.setUserFeedback(UserFeedback.kUserFeedbackPositive);
        return;
      case CrFeedbackOption.THUMBS_DOWN:
        this.apiProxy_.setUserFeedback(UserFeedback.kUserFeedbackNegative);
        return;
    }
  }
}

function userFeedbackToFeedbackOption(userFeedback: UserFeedback):
    CrFeedbackOption {
  switch (userFeedback) {
    case UserFeedback.kUserFeedbackUnspecified:
      return CrFeedbackOption.UNSPECIFIED;
    case UserFeedback.kUserFeedbackPositive:
      return CrFeedbackOption.THUMBS_UP;
    case UserFeedback.kUserFeedbackNegative:
      return CrFeedbackOption.THUMBS_DOWN;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'compose-app': ComposeAppElement;
  }
}

customElements.define(ComposeAppElement.is, ComposeAppElement);