chromium/ash/webui/recorder_app_ui/resources/components/recording-title-suggestion.ts

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

import 'chrome://resources/cros_components/badge/badge.js';
import 'chrome://resources/cros_components/chip/chip.js';
import './cra/cra-icon.js';
import './cra/cra-icon-button.js';
import './genai-error.js';
import './genai-feedback-buttons.js';
import './genai-placeholder.js';

import {Chip} from 'chrome://resources/cros_components/chip/chip.js';
import {
  css,
  html,
  map,
  PropertyDeclarations,
} from 'chrome://resources/mwc/lit/index.js';

import {i18n} from '../core/i18n.js';
import {ModelResponse} from '../core/on_device_model/types.js';
import {
  ComputedState,
  ReactiveLitElement,
  ScopedAsyncComputed,
} from '../core/reactive/lit.js';
import {
  assertExhaustive,
  assertExists,
  assertInstanceof,
} from '../core/utils/assert.js';

import {GenaiResultType} from './genai-error.js';

/**
 * The title suggestion popup in playback page of Recorder App.
 */
export class RecordingTitleSuggestion extends ReactiveLitElement {
  static override styles = css`
    :host {
      background-color: var(--cros-sys-base_elevated);
      border-radius: 12px;
      box-shadow: var(--cros-sys-app_elevation3);
      display: block;

      /* To have the border-radius applied to content. */
      overflow: hidden;
      z-index: 30;
    }

    #header {
      align-items: flex-end;
      display: flex;
      flex-flow: row;
      font: var(--cros-title-1-font);
      justify-content: space-between;
      padding: 4px 4px 0 16px;
      position: relative;

      & > cra-icon-button {
        margin: 0;
      }
    }

    #loading {
      padding: 16px;

      & > genai-placeholder::part(line-4) {
        display: none;
      }
    }

    #suggestions {
      align-items: flex-start;
      display: flex;
      flex-flow: column;
      gap: 8px;
      padding: 16px 16px 24px;
    }

    #footer {
      anchor-name: --footer;
      background-color: var(--cros-sys-app_base_shaded);
      box-sizing: border-box;
      font: var(--cros-annotation-2-font);

      /*
       * min-width and width are to avoid the footer from expanding the parent
       * width.
       */
      min-width: 100%;
      padding: 12px 16px;
      width: 0;

      & > a,
      & > a:visited {
        color: inherit;
      }
    }

    genai-feedback-buttons {
      --background-color: var(--cros-sys-app_base_shaded);

      bottom: -8px;

      /*
       * TODO: b/361221415 - Remove the old properties when stable Chrome
       * supports new one.
       */
      inset-area: top span-left;
      position: absolute;
      position-anchor: --footer;
      position-area: top span-left;
      z-index: 1;

      &::part(bottom-left-corner) {
        bottom: 8px;
      }
    }
  `;

  static override properties: PropertyDeclarations = {
    suggestedTitles: {attribute: false},
  };

  suggestedTitles: ScopedAsyncComputed<ModelResponse<string[]>|null>|null =
    null;

  get firstSuggestedTitleForTest(): Chip {
    return assertExists(this.shadowRoot?.querySelector('.suggestion'));
  }

  nthSuggestedTitleForTest(index: number): Chip {
    const allSuggestions = assertExists(
      this.shadowRoot?.querySelectorAll('.suggestion'),
    );
    return assertInstanceof(allSuggestions[index], Chip);
  }

  private onCloseClick() {
    this.dispatchEvent(new Event('close'));
  }

  private onSuggestionClick(ev: PointerEvent) {
    const target = assertInstanceof(ev.target, Chip);
    this.dispatchEvent(new CustomEvent('change', {detail: target.label}));
  }

  private renderSuggestion(suggestion: string) {
    // TODO: b/336963138 - Handle when the suggestion is too long to fit in one
    // line. Currently the cros-chip (and underlying md-chip) can't handle
    // either multiline or setting width / text-overflow: ellipsis, so we might
    // need to change to use our own component.
    return html`<cros-chip
      type="input"
      label=${suggestion}
      class="suggestion"
      @click=${this.onSuggestionClick}
    ></cros-chip>`;
  }

  private renderSuggestionFooter() {
    return html`
      <div id="footer">
        ${i18n.genAiDisclaimerText}
        <!-- TODO: b/336963138 - Add correct link -->
        <a href="javascript:;">${i18n.genAiLearnMoreLink}</a>
      </div>
      <genai-feedback-buttons></genai-feedback-buttons>
    `;
  }

  private renderContent() {
    // TODO(pihsun): There should also be a consent / download model / loading
    // state for title suggestion too. Implement it when the UI spec is done.
    if (this.suggestedTitles === null ||
        this.suggestedTitles.state !== ComputedState.DONE ||
        this.suggestedTitles.value === null) {
      // TOOD(pihsun): Handle error.
      return html`<div id="loading">
        <genai-placeholder></genai-placeholder>
      </div>`;
    }
    const suggestedTitles = this.suggestedTitles.value;
    switch (suggestedTitles.kind) {
      case 'error': {
        return html`<genai-error
          .error=${suggestedTitles.error}
          .resultType=${GenaiResultType.TITLE_SUGGESTION}
        ></genai-error>`;
      }
      case 'success': {
        const suggestions = map(
          suggestedTitles.result,
          (s) => this.renderSuggestion(s),
        );
        return html`<div id="suggestions">${suggestions}</div>
          ${this.renderSuggestionFooter()}`;
      }
      default:
        assertExhaustive(suggestedTitles);
    }
  }

  override render(): RenderResult {
    return html`
      <div id="header">
        <span>${i18n.titleSuggestionHeader}</span>
        <cra-icon-button
          buttonstyle="floating"
          size="small"
          shape="circle"
          @click=${this.onCloseClick}
        >
          <cra-icon slot="icon" name="close"></cra-icon>
        </cra-icon-button>
      </div>
      ${this.renderContent()}
    `;
  }
}

window.customElements.define(
  'recording-title-suggestion',
  RecordingTitleSuggestion,
);

declare global {
  interface HTMLElementTagNameMap {
    'recording-title-suggestion': RecordingTitleSuggestion;
  }
}