// 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 '../../../ui/components/markdown_view/markdown_view.js'; import '../../../ui/kit/kit.js'; 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 Root from '../../../core/root/root.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type {AiWidget, ComputedStyleAiWidget} from '../../../models/ai_assistance/agents/AiAgent.js'; import * as AiAssistanceModel from '../../../models/ai_assistance/ai_assistance.js'; import * as ComputedStyle from '../../../models/computed_style/computed_style.js'; import * as Marked from '../../../third_party/marked/marked.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as Input from '../../../ui/components/input/input.js'; import type * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js'; import type {MarkdownLitRenderer} from '../../../ui/components/markdown_view/MarkdownView.js'; import * as UIHelpers from '../../../ui/helpers/helpers.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 * as Elements from '../../elements/elements.js'; import chatMessageStyles from './chatMessage.css.js'; import {walkthroughTitle, WalkthroughView} from './WalkthroughView.js'; const {html, Directives: {ref, ifDefined}} = Lit; const lockedString = i18n.i18n.lockedString; const REPORT_URL = 'https://crbug.com/364805393' as Platform.DevToolsPath.UrlString; const SCROLL_ROUNDING_OFFSET = 1; /* * 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', /** * @description The title of the button that copies the AI-generated response to the clipboard. */ copyResponse: 'Copy response', /** * @description The error message when the request to the LLM failed for some reason. */ systemError: 'Something unforeseen happened and I can no longer continue. Try your request again and see if that resolves the issue. If this keeps happening, update Chrome to the latest version.', /** * @description The error message when the LLM gets stuck in a loop (max steps reached). */ maxStepsError: 'Seems like I am stuck with the investigation. It would be better if you start over.', /** * @description Displayed when the user stop the response */ stoppedResponse: 'You stopped this response', /** * @description Button text that confirm code execution that may affect the page. */ confirmActionRequestApproval: 'Continue', /** * @description Button text that cancels code execution that may affect the page. */ declineActionRequestApproval: 'Cancel', /** * @description The generic name of the AI agent (do not translate) */ ai: 'AI', /** * @description Gemini (do not translate) */ gemini: 'Gemini', /** * @description The fallback text when we can't find the user full name */ you: 'You', /** * @description The fallback text when a step has no title yet */ investigating: 'Investigating', /** * @description Prefix to the title of each thinking step of a user action is required to continue */ paused: 'Paused', /** * @description Heading text for the code block that shows the executed code. */ codeExecuted: 'Code executed', /** * @description Heading text for the code block that shows the code to be executed after side effect confirmation. */ codeToExecute: 'Code to execute', /** * @description Heading text for the code block that shows the returned data. */ dataReturned: 'Data returned', /** * @description Aria label for the check mark icon to be read by screen reader */ completed: 'Completed', /** * @description Aria label for the cancel icon to be read by screen reader */ canceled: 'Canceled', /** * @description Alt text for the image input (displayed in the chat messages) that has been sent to the model. */ imageInputSentToTheModel: 'Image input sent to the model', /** * @description Alt text for the account avatar. */ accountAvatar: 'Account avatar', /** * @description Title for the link which wraps the image input rendered in chat messages. */ openImageInNewTab: 'Open image in a new tab', /** * @description Alt text for image when it is not available. */ imageUnavailable: 'Image unavailable', /** * @description Title for the button that shows the thinking process (walkthrough). */ showThinking: 'Show thinking', /** * @description Title for the button that takes the user into other DevTools panels to reveal items the AI references. */ reveal: 'Reveal' } as const; export interface Step { isLoading: boolean; thought?: string; title?: string; code?: string; output?: string; widgets?: AiWidget[]; canceled?: boolean; requestApproval?: ConfirmSideEffectDialog; contextDetails?: [AiAssistanceModel.AiAgent.ContextDetail, ...AiAssistanceModel.AiAgent.ContextDetail[]]; } export interface ConfirmSideEffectDialog { description: string|null; onAnswer: (result: boolean) => void; } export const enum ChatMessageEntity { MODEL = 'model', USER = 'user', } export interface AnswerPart { type: 'answer'; text: string; suggestions?: [string, ...string[]]; } export interface StepPart { type: 'step'; step: Step; } export type ModelMessagePart = AnswerPart|StepPart; export interface UserChatMessage { entity: ChatMessageEntity.USER; text: string; imageInput?: Host.AidaClient.Part; } export interface ModelChatMessage { entity: ChatMessageEntity.MODEL; parts: ModelMessagePart[]; error?: AiAssistanceModel.AiAgent.ErrorType; rpcId?: Host.AidaClient.RpcGlobalId; } export type Message = UserChatMessage|ModelChatMessage; export interface RatingViewInput { currentRating?: Host.AidaClient.Rating; onRatingClick: (rating: Host.AidaClient.Rating) => void; showRateButtons: boolean; } export interface ActionViewInput { onReportClick: () => void; onCopyResponseClick: () => void; showActions: boolean; } 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 ChatMessageViewInput = MessageInput&RatingViewInput&ActionViewInput&SuggestionViewInput&FeedbackFormViewInput; export interface ViewOutput { suggestionsLeftScrollButtonContainer?: Element; suggestionsScrollContainer?: Element; suggestionsRightScrollButtonContainer?: Element; } export interface MessageInput { suggestions?: [string, ...string[]]; message: Message; isLoading: boolean; isReadOnly: boolean; isLastMessage: boolean; canShowFeedbackForm: boolean; userInfo: Pick; markdownRenderer: MarkdownLitRenderer; onSuggestionClick: (suggestion: string) => void; onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void; onCopyResponseClick: (message: ModelChatMessage) => void; walkthrough: { onOpen: (message: ModelChatMessage) => void, isExpanded: boolean, onToggle: (isOpen: boolean) => void, isInlined: boolean, }; } export const DEFAULT_VIEW = (input: ChatMessageViewInput, output: ViewOutput, target: HTMLElement): void => { const message = input.message; if (message.entity === ChatMessageEntity.USER) { const givenName = AiAssistanceModel.AiUtils.isGeminiBranding() ? input.userInfo.accountGivenName : ''; const name = givenName || input.userInfo.accountFullName || lockedString(UIStringsNotTranslate.you); const image = input.userInfo.accountImage ? html`${` : html``; const imageInput = message.imageInput && 'inlineData' in message.imageInput ? renderImageChatMessage(message.imageInput.inlineData) : Lit.nothing; // clang-format off Lit.render(html`
${image}

${name}

${imageInput}
${renderTextAsMarkdown(message.text, input.markdownRenderer)}
`, target); // clang-format on return; } const steps = message.parts.filter(part => part.type === 'step').map(part => part.step); const icon = AiAssistanceModel.AiUtils.getIconName(); const aiAssistanceV2 = Root.Runtime.hostConfig.devToolsAiAssistanceV2?.enabled; // clang-format off Lit.render(html`

${AiAssistanceModel.AiUtils.isGeminiBranding() ? lockedString(UIStringsNotTranslate.gemini) : lockedString(UIStringsNotTranslate.ai)}

${aiAssistanceV2 ? renderWalkthroughUI(input, steps) : Lit.nothing} ${Lit.Directives.repeat( message.parts, (_, index) => index, (part, index) => { const isLastPart = index === message.parts.length - 1; if (part.type === 'answer') { return html`

${renderTextAsMarkdown(part.text, input.markdownRenderer, { animate: !input.isReadOnly && input.isLoading && isLastPart && input.isLastMessage })}

`; } if (!aiAssistanceV2 && part.type === 'step') { return renderStep({ step: part.step, isLoading: input.isLoading, markdownRenderer: input.markdownRenderer, isLast: isLastPart, }); } return Lit.nothing; }, )} ${renderError(message)} ${input.showActions ? renderActions(input, output) : Lit.nothing}
`, target); // clang-format on }; export type View = typeof DEFAULT_VIEW; function renderTextAsMarkdown(text: string, markdownRenderer: MarkdownLitRenderer, {animate, ref: refFn}: { animate?: boolean, ref?: (element?: Element) => void, } = {}): Lit.TemplateResult { let tokens = []; try { tokens = Marked.Marked.lexer(text); for (const token of tokens) { // Try to render all the tokens to make sure that // they all have a template defined for them. If there // isn't any template defined for a token, we'll fallback // to rendering the text as plain text instead of markdown. markdownRenderer.renderToken(token); } } catch { // The tokens were not parsed correctly or // one of the tokens are not supported, so we // continue to render this as text. return html`${text}`; } // clang-format off return html` `; // clang-format on } export function titleForStep(step: Step): string { return step.title ?? `${lockedString(UIStringsNotTranslate.investigating)}…`; } function renderTitle(step: Step): Lit.LitTemplate { const paused = step.requestApproval ? html`${lockedString(UIStringsNotTranslate.paused)}: ` : Lit.nothing; return html`${paused}${titleForStep(step)}`; } function renderStepCode(step: Step): Lit.LitTemplate { if (!step.code && !step.output) { return Lit.nothing; } // If there is no "output" yet, it means we didn't execute the code yet (e.g. maybe it is still waiting for confirmation from the user) // thus we show "Code to execute" text rather than "Code executed" text on the heading of the code block. const codeHeadingText = (step.output && !step.canceled) ? lockedString(UIStringsNotTranslate.codeExecuted) : lockedString(UIStringsNotTranslate.codeToExecute); // If there is output, we don't show notice on this code block and instead show // it in the data returned code block. // clang-format off const code = step.code ? html`
` : Lit.nothing; const output = step.output ? html`
` : Lit.nothing; return html`
${code}${output}
`; // clang-format on } function renderStepDetails({ step, markdownRenderer, isLast, }: { step: Step, markdownRenderer: MarkdownLitRenderer, isLast: boolean, }): Lit.LitTemplate { const sideEffects = isLast && step.requestApproval ? renderSideEffectConfirmationUi(step) : Lit.nothing; const thought = step.thought ? html`

${renderTextAsMarkdown(step.thought, markdownRenderer)}

` : Lit.nothing; // clang-format off const contextDetails = step.contextDetails ? html`${Lit.Directives.repeat( step.contextDetails, contextDetail => { return html`
`; }, )}` : Lit.nothing; return html`
${thought} ${renderStepCode(step)} ${sideEffects} ${contextDetails}
`; // clang-format on } function renderWalkthroughSidebarButton( input: ChatMessageViewInput, lastStep: Step, ): Lit.LitTemplate { const {message, walkthrough} = input; if (walkthrough.isInlined) { return Lit.nothing; } const title = walkthroughTitle({ isLoading: input.isLoading, lastStep, }); // clang-format off return html`
${input.isLoading ? html`` : Lit.nothing} { if(walkthrough.isExpanded) { walkthrough.onToggle(false); } else { // Can't just toggle the visibility here; we need to ensure we // update the state with this message as the user could have had // the walkthrough open with an alternative message. walkthrough.onOpen(message as ModelChatMessage); } }} >${title}
`; // clang-format on } /** * Responsible for rendering the AI Walkthrough UI. This can take different * shapes and involve different parts depending on if the walkthrough is * inlined, expanded, or if we have side-effect steps that need the user to * view them. */ function renderWalkthroughUI(input: ChatMessageViewInput, steps: Step[]): Lit.LitTemplate { const lastStep = steps.at(-1); if (!lastStep) { // No steps = no walkthrough UI in the chat view. return Lit.nothing; } const sideEffectSteps = steps.filter(s => s.requestApproval); // If the walkthrough is in the sidebar, we render a button into the // ChatView to open it. const openWalkThroughSidebarButton = !input.walkthrough.isInlined ? renderWalkthroughSidebarButton(input, lastStep) : Lit.nothing; // If there are side-effect steps, and the walkthrough is not open, we render // those inline so that the user can see them and approve them. // Note: this is a temporary approach and needs more UX discussion; b/487921578 // clang-format off const sideEffectStepsUI = !input.walkthrough.isInlined && !input.walkthrough.isExpanded && sideEffectSteps.length > 0 ? sideEffectSteps.map(step => html`
${renderStep({ step, isLoading: input.isLoading, markdownRenderer: input.markdownRenderer, isLast: true })}
`) : Lit.nothing; // clang-format on // If the walkthrough is inlined (narrow width screens), render it here. // Note that we force it open if there is a side-effect. // clang-format off const walkthroughInline = input.walkthrough.isInlined ? html`
Boolean(step.requestApproval))), onToggle: input.walkthrough.onToggle, })}>
` : Lit.nothing; return html` ${openWalkThroughSidebarButton} ${sideEffectStepsUI} ${walkthroughInline} `; // clang-format on } function renderStepBadge({step, isLoading, isLast}: { step: Step, isLoading: boolean, isLast: boolean, }): Lit.LitTemplate { if (isLoading && isLast && !step.requestApproval) { return html``; } let iconName = 'checkmark'; let ariaLabel: string|undefined = lockedString(UIStringsNotTranslate.completed); let role: 'button'|undefined = 'button'; if (isLast && step.requestApproval) { role = undefined; ariaLabel = undefined; iconName = 'pause-circle'; } else if (step.canceled) { ariaLabel = lockedString(UIStringsNotTranslate.canceled); iconName = 'cross'; } return html``; } export function renderStep({step, isLoading, markdownRenderer, isLast}: { step: Step, isLoading: boolean, markdownRenderer: MarkdownLitRenderer, isLast: boolean, }): Lit.LitTemplate { const shouldRenderWidgets = Boolean(step.widgets?.length && Root.Runtime.hostConfig.devToolsAiAssistanceV2?.enabled); const stepClasses = Lit.Directives.classMap({ step: true, empty: !step.thought && !step.code && !step.contextDetails && !step.requestApproval, paused: Boolean(step.requestApproval), canceled: Boolean(step.canceled), }); // clang-format off return html`
${renderStepBadge({ step, isLoading, isLast })} ${renderTitle(step)}
${renderStepDetails({step, markdownRenderer, isLast})}
${shouldRenderWidgets ? html`
${Lit.Directives.until(renderStepWidgets(step))}
` : Lit.nothing }`; // clang-format on } interface WidgetMakerResponse { renderedWidget: Lit.LitTemplate; revealable: unknown; } async function makeComputedStyleWidget(widgetData: ComputedStyleAiWidget): Promise { const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (!target) { return null; } const node = new SDK.DOMModel.DeferredDOMNode( target, widgetData.data.backendNodeId, ); const resolved = await node.resolvePromise(); if (!resolved) { return null; } const model = new ComputedStyle.ComputedStyleModel.ComputedStyleModel(resolved); const styles = new ComputedStyle.ComputedStyleModel.ComputedStyle(resolved, widgetData.data.computedStyles); const widgetConfig = UI.Widget.widgetConfig(Elements.ComputedStyleWidget.ComputedStyleWidget, { nodeStyle: styles, matchedStyles: widgetData.data.matchedCascade, // This disables showing the nested traces and detailed information in the widget. propertyTraces: null, computedStyleModel: model, allowUserControl: false, filterText: new RegExp(widgetData.data.properties.join('|'), 'i') }); // clang-format off const widget = html``; // clang-format on return {renderedWidget: widget, revealable: new Elements.ElementsPanel.NodeComputedStyles(resolved)}; } function renderWidgetResponse(response: WidgetMakerResponse|null): Lit.LitTemplate { if (response === null) { return Lit.nothing; } function onReveal(): void { if (response === null) { return; } void Common.Revealer.reveal(response?.revealable); } // clang-format off return html`
${response.renderedWidget}
${lockedString(UIStringsNotTranslate.reveal)}
`; // clang-format on } /** * Renders AI-defined UI widgets within a step. * When a ModelChatMessage contains a WidgetPart, the ChatMessage component * iterates through the `step.widgets` array. For each widget, it determines * the appropriate rendering logic based on the `widgetData.name`. * * Currently, only 'COMPUTED_STYLES' widgets are supported. For these, the * `makeComputedStyleWidget` function is called to construct the necessary * data and configuration for the `Elements.ComputedStyleWidget.ComputedStyleWidget` * component. The widget is then rendered using the `` * custom element, which dynamically instantiates and displays the specified * UI.Widget subclass with the provided configuration. * * This allows for a flexible and extensible system where new widget types * can be added to the AI responses and rendered in DevTools by adding * corresponding `make...Widget` functions and handling them here. */ async function renderStepWidgets(step: Step): Promise { if (!step.widgets || step.widgets.length === 0) { return Lit.nothing; } const ui = await Promise.all(step.widgets.map(async widgetData => { if (widgetData.name === 'COMPUTED_STYLES') { const response = await makeComputedStyleWidget(widgetData); return renderWidgetResponse(response); } return Lit.nothing; })); return html`${ui}`; } function renderSideEffectConfirmationUi(step: Step): Lit.LitTemplate { if (!step.requestApproval) { return Lit.nothing; } // clang-format off return html`
${step.requestApproval.description ? html`

${step.requestApproval.description}

` : Lit.nothing}
step.requestApproval?.onAnswer(false)} >${lockedString( UIStringsNotTranslate.declineActionRequestApproval, )} step.requestApproval?.onAnswer(true)} >${ lockedString(UIStringsNotTranslate.confirmActionRequestApproval) }
`; // clang-format on } function renderError(message: ModelChatMessage): Lit.LitTemplate { if (message.error) { let errorMessage; switch (message.error) { case AiAssistanceModel.AiAgent.ErrorType.UNKNOWN: case AiAssistanceModel.AiAgent.ErrorType.BLOCK: errorMessage = UIStringsNotTranslate.systemError; break; case AiAssistanceModel.AiAgent.ErrorType.MAX_STEPS: errorMessage = UIStringsNotTranslate.maxStepsError; break; case AiAssistanceModel.AiAgent.ErrorType.ABORT: return html`

${ lockedString(UIStringsNotTranslate.stoppedResponse)}

`; } return html`

${lockedString(errorMessage)}

`; } return Lit.nothing; } function renderImageChatMessage(inlineData: Host.AidaClient.MediaBlob): Lit.LitTemplate { if (inlineData.data === AiAssistanceModel.AiConversation.NOT_FOUND_IMAGE_DATA) { // clang-format off return html`
`; // clang-format on } const imageUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`; // clang-format off return html` ${UIStringsNotTranslate.imageInputSentToTheModel} `; // clang-format on } function renderActions(input: ChatMessageViewInput, output: ViewOutput): Lit.LitTemplate { // clang-format off return html`
${input.showRateButtons ? html` input.onRatingClick(Host.AidaClient.Rating.POSITIVE)} > input.onRatingClick(Host.AidaClient.Rating.NEGATIVE)} >
`: Lit.nothing}
${input.suggestions ? html`
{ output.suggestionsScrollContainer = element; })}> ${input.suggestions.map(suggestion => html` input.onSuggestionClick(suggestion)} >${suggestion}`)}
` : Lit.nothing}
${input.isShowingFeedbackForm ? html` ` : Lit.nothing} `; // clang-format on } export class ChatMessage extends UI.Widget.Widget { message: Message = {entity: ChatMessageEntity.USER, text: ''}; isLoading = false; isReadOnly = false; canShowFeedbackForm = false; isLastMessage = false; userInfo: Pick = {}; markdownRenderer!: MarkdownLitRenderer; onSuggestionClick: (suggestion: string) => void = () => {}; onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void = () => {}; onCopyResponseClick: (message: ModelChatMessage) => void = () => {}; walkthrough: MessageInput['walkthrough'] = { onOpen: () => {}, onToggle: () => {}, isInlined: false, isExpanded: 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(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 { this.#view( { message: this.message, isLoading: this.isLoading, isReadOnly: this.isReadOnly, canShowFeedbackForm: this.canShowFeedbackForm, userInfo: this.userInfo, markdownRenderer: this.markdownRenderer, isLastMessage: this.isLastMessage, onSuggestionClick: this.onSuggestionClick, onRatingClick: this.#handleRateClick.bind(this), onReportClick: () => UIHelpers.openInNewTab(REPORT_URL), onCopyResponseClick: () => { if (this.message.entity === ChatMessageEntity.MODEL) { this.onCopyResponseClick(this.message); } }, 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, // Props for actions logic showActions: !(this.isLastMessage && this.isLoading), showRateButtons: this.message.entity === ChatMessageEntity.MODEL && !!this.message.rpcId, suggestions: (this.isLastMessage && this.message.entity === ChatMessageEntity.MODEL && !this.isReadOnly && this.message.parts.at(-1)?.type === 'answer') ? (this.message.parts.at(-1) as AnswerPart).suggestions : undefined, currentRating: this.#currentRating, isShowingFeedbackForm: this.#isShowingFeedbackForm, onFeedbackSubmit: this.onFeedbackSubmit, walkthrough: this.walkthrough, }, 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 { super.willHide(); 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 if (this.message.entity === ChatMessageEntity.MODEL && this.message.rpcId) { this.onFeedbackSubmit(this.message.rpcId, Host.AidaClient.Rating.SENTIMENT_UNSPECIFIED); } void this.performUpdate(); return; } this.#currentRating = rating; this.#isShowingFeedbackForm = this.canShowFeedbackForm; if (this.message.entity === ChatMessageEntity.MODEL && this.message.rpcId) { this.onFeedbackSubmit(this.message.rpcId, 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; } if (this.message.entity === ChatMessageEntity.MODEL && this.message.rpcId) { this.onFeedbackSubmit(this.message.rpcId, this.#currentRating, input); } this.#isShowingFeedbackForm = false; this.#isSubmitButtonDisabled = true; void this.performUpdate(); } }