UNPKG

chrome-devtools-frontend

Version:
404 lines (369 loc) • 15.3 kB
// Copyright 2024 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-lit-render-outside-of-view */ import * as Common from '../../../core/common/common.js'; import * as Host from '../../../core/host/host.js'; import * as i18n from '../../../core/i18n/i18n.js'; import type * as Platform from '../../../core/platform/platform.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as Input from '../../../ui/components/input/input.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import userActionRowStyles from './userActionRow.css.js'; const {html, Directives: {ref}} = Lit; /* * Strings that don't need to be translated at this time. */ const UIStringsNotTranslate = { /** * @description The title of the button that allows submitting positive * feedback about the response for AI assistance. */ thumbsUp: 'Good response', /** * @description The title of the button that allows submitting negative * feedback about the response for AI assistance. */ thumbsDown: 'Bad response', /** * @description The placeholder text for the feedback input. */ provideFeedbackPlaceholder: 'Provide additional feedback', /** * @description The disclaimer text that tells the user what will be shared * and what will be stored. */ disclaimer: 'Submitted feedback will also include your conversation', /** * @description The button text for the action of submitting feedback. */ submit: 'Submit', /** * @description The header of the feedback form asking. */ whyThisRating: 'Why did you choose this rating? (optional)', /** * @description The button text for the action that hides the feedback form. */ close: 'Close', /** * @description The title of the button that opens a page to report a legal * issue with the AI assistance message. */ report: 'Report legal issue', /** * @description The title of the button for scrolling to see next suggestions */ scrollToNext: 'Scroll to next suggestions', /** * @description The title of the button for scrolling to see previous suggestions */ scrollToPrevious: 'Scroll to previous suggestions', } as const; const lockedString = i18n.i18n.lockedString; const REPORT_URL = 'https://support.google.com/legal/troubleshooter/1114905?hl=en#ts=1115658%2C13380504' as Platform.DevToolsPath.UrlString; const SCROLL_ROUNDING_OFFSET = 1; export interface RatingViewInput { currentRating?: Host.AidaClient.Rating; onRatingClick: (rating: Host.AidaClient.Rating) => void; showRateButtons: boolean; onReportClick: () => void; } export interface SuggestionViewInput { suggestions?: [string, ...string[]]; scrollSuggestionsScrollContainer: (direction: 'left'|'right') => void; onSuggestionsScrollOrResize: () => void; onSuggestionClick: (suggestion: string) => void; } export interface FeedbackFormViewInput { isShowingFeedbackForm: boolean; onSubmit: (event: SubmitEvent) => void; onClose: () => void; onInputChange: (input: string) => void; isSubmitButtonDisabled: boolean; } export type UserActionRowViewInput = RatingViewInput&SuggestionViewInput&FeedbackFormViewInput; export interface ViewOutput { suggestionsLeftScrollButtonContainer?: Element; suggestionsScrollContainer?: Element; suggestionsRightScrollButtonContainer?: Element; } export interface UserActionRowWidgetParams { showRateButtons: boolean; onFeedbackSubmit: (rate: Host.AidaClient.Rating, feedback?: string) => void; suggestions?: [string, ...string[]]; onSuggestionClick: (suggestion: string) => void; canShowFeedbackForm: boolean; } export const DEFAULT_VIEW = (input: UserActionRowViewInput, output: ViewOutput, target: HTMLElement): void => { // clang-format off Lit.render(html` <style>${Input.textInputStyles}</style> <style>${userActionRowStyles}</style> <div class="ai-assistance-feedback-row"> <div class="rate-buttons"> ${input.showRateButtons ? html` <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'thumb-up', toggledIconName: 'thumb-up-filled', toggled: input.currentRating === Host.AidaClient.Rating.POSITIVE, toggleType: Buttons.Button.ToggleType.PRIMARY, title: lockedString(UIStringsNotTranslate.thumbsUp), jslogContext: 'thumbs-up', } as Buttons.Button.ButtonData} @click=${() => input.onRatingClick(Host.AidaClient.Rating.POSITIVE)} ></devtools-button> <devtools-button .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'thumb-down', toggledIconName: 'thumb-down-filled', toggled: input.currentRating === Host.AidaClient.Rating.NEGATIVE, toggleType: Buttons.Button.ToggleType.PRIMARY, title: lockedString(UIStringsNotTranslate.thumbsDown), jslogContext: 'thumbs-down', } as Buttons.Button.ButtonData} @click=${() => input.onRatingClick(Host.AidaClient.Rating.NEGATIVE)} ></devtools-button> <div class="vertical-separator"></div> `: Lit.nothing} <devtools-button .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.report), iconName: 'report', jslogContext: 'report', } as Buttons.Button.ButtonData } @click=${input.onReportClick} ></devtools-button> </div> ${input.suggestions ? html`<div class="suggestions-container"> <div class="scroll-button-container left hidden" ${ref(element => { output.suggestionsLeftScrollButtonContainer = element; } )}> <devtools-button class='scroll-button' .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'chevron-left', title: lockedString(UIStringsNotTranslate.scrollToPrevious), jslogContext: 'chevron-left', } as Buttons.Button.ButtonData} @click=${() => input.scrollSuggestionsScrollContainer('left')} ></devtools-button> </div> <div class="suggestions-scroll-container" @scroll=${input.onSuggestionsScrollOrResize} ${ref(element => { output.suggestionsScrollContainer = element; })}> ${input.suggestions.map(suggestion => html`<devtools-button class='suggestion' .data=${{ variant: Buttons.Button.Variant.OUTLINED, title: suggestion, jslogContext: 'suggestion', } as Buttons.Button.ButtonData} @click=${() => input.onSuggestionClick(suggestion)} >${suggestion}</devtools-button>`)} </div> <div class="scroll-button-container right hidden" ${ref(element => { output.suggestionsRightScrollButtonContainer = element; })}> <devtools-button class='scroll-button' .data=${{ variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'chevron-right', title: lockedString(UIStringsNotTranslate.scrollToNext), jslogContext: 'chevron-right', } as Buttons.Button.ButtonData} @click=${() => input.scrollSuggestionsScrollContainer('right')} ></devtools-button> </div> </div>` : Lit.nothing} </div> ${input.isShowingFeedbackForm ? html` <form class="feedback-form" @submit=${input.onSubmit}> <div class="feedback-header"> <h4 class="feedback-title">${lockedString( UIStringsNotTranslate.whyThisRating, )}</h4> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.close)} @click=${input.onClose} .data=${ { variant: Buttons.Button.Variant.ICON, iconName: 'cross', size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.close), jslogContext: 'close', } as Buttons.Button.ButtonData } ></devtools-button> </div> <input type="text" class="devtools-text-input feedback-input" @input=${(event: KeyboardEvent) => input.onInputChange((event.target as HTMLInputElement).value)} placeholder=${lockedString( UIStringsNotTranslate.provideFeedbackPlaceholder, )} jslog=${VisualLogging.textField('feedback').track({ keydown: 'Enter' })} > <span class="feedback-disclaimer">${ lockedString(UIStringsNotTranslate.disclaimer) }</span> <div> <devtools-button aria-label=${lockedString(UIStringsNotTranslate.submit)} .data=${ { type: 'submit', disabled: input.isSubmitButtonDisabled, variant: Buttons.Button.Variant.OUTLINED, size: Buttons.Button.Size.SMALL, title: lockedString(UIStringsNotTranslate.submit), jslogContext: 'send', } as Buttons.Button.ButtonData } >${ lockedString(UIStringsNotTranslate.submit) }</devtools-button> </div> </div> </form> ` : Lit.nothing} `, target, {host: target}); // clang-format on }; export type View = typeof DEFAULT_VIEW; /** * This presenter has too many responsibilities (rating buttons, feedback * form, suggestions). */ export class UserActionRow extends UI.Widget.Widget implements UserActionRowWidgetParams { showRateButtons = false; onFeedbackSubmit: (rate: Host.AidaClient.Rating, feedback?: string) => void = () => {}; suggestions: [string, ...string[]]|undefined; onSuggestionClick: (suggestion: string) => void = () => {}; canShowFeedbackForm = false; #suggestionsResizeObserver = new ResizeObserver(() => this.#handleSuggestionsScrollOrResize()); #suggestionsEvaluateLayoutThrottler = new Common.Throttler.Throttler(50); #feedbackValue = ''; #currentRating: Host.AidaClient.Rating|undefined; #isShowingFeedbackForm = false; #isSubmitButtonDisabled = true; #view: View; #viewOutput: ViewOutput = {}; constructor(element?: HTMLElement, view?: View) { super(false, false, element); this.#view = view ?? DEFAULT_VIEW; } override wasShown(): void { super.wasShown(); void this.performUpdate(); this.#evaluateSuggestionsLayout(); if (this.#viewOutput.suggestionsScrollContainer) { this.#suggestionsResizeObserver.observe(this.#viewOutput.suggestionsScrollContainer); } } override performUpdate(): Promise<void>|void { this.#view( { onSuggestionClick: this.onSuggestionClick, onRatingClick: this.#handleRateClick.bind(this), onReportClick: () => UI.UIUtils.openInNewTab(REPORT_URL), scrollSuggestionsScrollContainer: this.#scrollSuggestionsScrollContainer.bind(this), onSuggestionsScrollOrResize: this.#handleSuggestionsScrollOrResize.bind(this), onSubmit: this.#handleSubmit.bind(this), onClose: this.#handleClose.bind(this), onInputChange: this.#handleInputChange.bind(this), isSubmitButtonDisabled: this.#isSubmitButtonDisabled, showRateButtons: this.showRateButtons, suggestions: this.suggestions, currentRating: this.#currentRating, isShowingFeedbackForm: this.#isShowingFeedbackForm, }, this.#viewOutput, this.contentElement); } #handleInputChange(value: string): void { this.#feedbackValue = value; const disableSubmit = !value; if (disableSubmit !== this.#isSubmitButtonDisabled) { this.#isSubmitButtonDisabled = disableSubmit; void this.performUpdate(); } } #evaluateSuggestionsLayout = (): void => { const suggestionsScrollContainer = this.#viewOutput.suggestionsScrollContainer; const leftScrollButtonContainer = this.#viewOutput.suggestionsLeftScrollButtonContainer; const rightScrollButtonContainer = this.#viewOutput.suggestionsRightScrollButtonContainer; if (!suggestionsScrollContainer || !leftScrollButtonContainer || !rightScrollButtonContainer) { return; } const shouldShowLeftButton = suggestionsScrollContainer.scrollLeft > SCROLL_ROUNDING_OFFSET; const shouldShowRightButton = suggestionsScrollContainer.scrollLeft + (suggestionsScrollContainer as HTMLElement).offsetWidth + SCROLL_ROUNDING_OFFSET < suggestionsScrollContainer.scrollWidth; leftScrollButtonContainer.classList.toggle('hidden', !shouldShowLeftButton); rightScrollButtonContainer.classList.toggle('hidden', !shouldShowRightButton); }; override willHide(): void { this.#suggestionsResizeObserver.disconnect(); } #handleSuggestionsScrollOrResize(): void { void this.#suggestionsEvaluateLayoutThrottler.schedule(() => { this.#evaluateSuggestionsLayout(); return Promise.resolve(); }); } #scrollSuggestionsScrollContainer(direction: 'left'|'right'): void { const suggestionsScrollContainer = this.#viewOutput.suggestionsScrollContainer; if (!suggestionsScrollContainer) { return; } suggestionsScrollContainer.scroll({ top: 0, left: direction === 'left' ? suggestionsScrollContainer.scrollLeft - suggestionsScrollContainer.clientWidth : suggestionsScrollContainer.scrollLeft + suggestionsScrollContainer.clientWidth, behavior: 'smooth', }); } #handleRateClick(rating: Host.AidaClient.Rating): void { if (this.#currentRating === rating) { this.#currentRating = undefined; this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; // This effectively reset the user rating this.onFeedbackSubmit(Host.AidaClient.Rating.SENTIMENT_UNSPECIFIED); void this.performUpdate(); return; } this.#currentRating = rating; this.#isShowingFeedbackForm = this.canShowFeedbackForm; this.onFeedbackSubmit(rating); void this.performUpdate(); } #handleClose(): void { this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; void this.performUpdate(); } #handleSubmit(ev: SubmitEvent): void { ev.preventDefault(); const input = this.#feedbackValue; if (!this.#currentRating || !input) { return; } this.onFeedbackSubmit(this.#currentRating, input); this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; void this.performUpdate(); } }