chrome-devtools-frontend
Version:
Chrome DevTools UI
2,469 lines • 92.5 kB
text/typescript
// 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 * as Platform from '../../../core/platform/platform.js';
import * as SDK from '../../../core/sdk/sdk.js';
import * as TextUtils from '../../../core/text_utils/text_utils.js';
import type * as Protocol from '../../../generated/protocol.js';
import type {
AiWidget, BottomUpTreeAiWidget, ComputedStyleAiWidget, CoreVitalsAiWidget, DomTreeAiWidget, LighthouseReportAiWidget,
NetworkRequestGeneralHeadersAiWidget, NetworkRequestsListAiWidget, NetworkTrackAiWidget, PerfInsightAiWidget,
PerformanceTraceAiWidget, SourceCodeAiWidget, SourceFileAiWidget, SourceFilesListAiWidget, StorageBreakdownAiWidget,
StylePropertiesAiWidget, TimelineEventSummaryAiWidget,
TimelineRangeSummaryAiWidget} 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 Formatter from '../../../models/formatter/formatter.js';
import * as Trace from '../../../models/trace/trace.js';
import * as Workspace from '../../../models/workspace/workspace.js';
import * as PanelsCommon from '../../../panels/common/common.js';
import * as TraceBounds from '../../../services/trace_bounds/trace_bounds.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 Snackbars from '../../../ui/components/snackbars/snackbars.js';
import * as UIHelpers from '../../../ui/helpers/helpers.js';
import type * as PerfUI from '../../../ui/legacy/components/perf_ui/perf_ui.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 Application from '../../application/application.js';
import * as Elements from '../../elements/elements.js';
import * as Lighthouse from '../../lighthouse/lighthouse.js';
import * as NetworkForward from '../../network/forward/forward.js';
import * as Network from '../../network/network.js';
import * as TimelineComponents from '../../timeline/components/components.js';
import type {BaseInsightComponent} from '../../timeline/components/insights/BaseInsightComponent.js';
import * as TimelineInsights from '../../timeline/components/insights/insights.js';
import * as Timeline from '../../timeline/timeline.js';
import * as TimelineUtils from '../../timeline/utils/utils.js';
import {PanelUtils} from '../../utils/utils.js';
import chatMessageStyles from './chatMessage.css.js';
import {getButtonLabel} from './WalkthroughUtils.js';
import {walkthroughCloseTitle, walkthroughTitle, WalkthroughView} from './WalkthroughView.js';
const {html, Directives: {ref, ifDefined}} = Lit;
const lockedString = i18n.i18n.lockedString;
const {widget} = UI.Widget;
const REPORT_URL = 'https://crbug.com/508304827' 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 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 user is out of quota or rate limited.
*/
quotaError: 'You reached your limit for AI assistance requests. Try again later.',
/**
* @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 The error message when the LLM selects context from a different origin.
*/
crossOriginError: 'I have selected the new context but you will have to start a new chat.',
/**
* @description The error message when the request payload is too large.
*/
payloadTooLargeError: 'The request payload is too large. Please try a smaller image or a screenshot.',
/**
* @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 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 spinner to be read by screen reader when a step is in progress.
*/
inProgress: 'In progress',
/**
* @description Aria label for the aborted icon to be read by screen reader
*/
aborted: 'Aborted',
/**
* @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 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 takes the user into other DevTools panels to reveal items the AI references.
*/
reveal: 'Reveal',
/**
* @description Title used for revealing the performance trace.
*/
revealTrace: 'Reveal trace',
/**
* @description Accessible label for the reveal button in the computed styles widget.
*/
revealComputedStyles: 'Reveal computed styles',
/**
* @description Accessible label for the reveal button in the core web vitals widget.
*/
revealCoreWebVitals: 'Reveal Core Web Vitals',
/**
* @description Accessible label for the reveal button in the style properties widget.
*/
revealStyleProperties: 'Reveal style properties',
/**
* @description Accessible label for the reveal button in the LCP breakdown widget.
*/
revealLcpBreakdown: 'Reveal LCP breakdown',
/**
* @description Accessible label for the reveal button in the LCP discovery widget.
*/
revealLcpDiscovery: 'Reveal LCP discovery',
/**
* @description Accessible label for the reveal button in the layout shift culprits widget.
*/
revealClsCulprits: 'Reveal layout shift culprits',
/**
* @description Accessible label for the reveal button in the render-blocking requests widget.
*/
revealRenderBlockingBreakdown: 'Reveal render-blocking requests',
/**
* @description Accessible label for the reveal button in the performance summary widget.
*/
revealPerformanceSummary: 'Reveal performance summary',
/**
* @description Accessible label for the reveal button in the network track widget.
*/
revealNetworkActivity: 'Reveal network activity',
/**
* @description Accessible label for the reveal button in the bottom up thread activity widget.
*/
revealBottomUpTree: 'Reveal bottom-up thread activity',
/**
* @description Accessible label for the reveal button in the network dependency tree widget.
*/
revealNetworkDependencyTree: 'Reveal network dependency tree',
/**
* @description Accessible label for the reveal button in the 3rd parties widget.
*/
revealThirdParties: 'Reveal 3rd parties',
/**
* @description Title for the core web vitals widget.
*/
coreVitals: 'Core Web Vitals',
/**
* @description Title for the Lighthouse report widget.
*/
lighthouseReport: 'Lighthouse report',
/**
* @description Accessible label for the reveal button in the Lighthouse report widget.
*/
revealLighthouse: 'Reveal Lighthouse report',
/**
* @description Title for the Timeline event summary widget.
*/
timelineEventSummary: 'Event summary',
/**
* @description Accessible label for the reveal button in the Timeline event summary widget.
*/
revealTimelineEventSummary: 'Reveal event',
/**
* @description Title for the LCP breakdown widget.
*/
lcpBreakdown: 'LCP breakdown',
/**
* @description Title for the LCP discovery widget.
*/
lcpDiscovery: 'LCP discovery',
/**
* @description Title for the layout shift culprits widget.
*/
clsCulprits: 'Layout shift culprits',
/**
* @description Title for the render-blocking requests widget.
*/
renderBlockingBreakdown: 'Render-blocking requests',
/**
* @description Title for the network dependency tree widget.
*/
networkDependencyTree: 'Network dependency tree',
/**
* @description Title for the 3rd parties widget.
*/
thirdParties: '3rd parties',
/**
* @description Title for the performance summary widget.
*/
performanceSummary: 'Performance summary',
/**
* @description Title for the network activity summary widget.
*/
networkActivitySummary: 'Network activity',
/**
* @description The title of the button that allows exporting the conversation for agents.
*/
exportForAgents: 'Copy to coding agent',
/**
* @description Title for the bottom up thread activity widget.
*/
bottomUpTree: 'Bottom-up thread activity',
/**
* @description Accessible label for the reveal button in the forced reflow widget.
*/
revealForcedReflow: 'Reveal forced reflow',
/**
* @description Title for the forced reflow widget.
*/
forcedReflow: 'Forced reflow',
/**
* @description Accessible label for the reveal button in the cache widget.
*/
revealCache: 'Reveal efficient cache lifetimes',
/**
* @description Title for the cache widget.
*/
cache: 'Efficient cache lifetimes',
/**
* @description Accessible label for the reveal button in the INP breakdown widget.
*/
revealInpBreakdown: 'Reveal INP breakdown',
/**
* @description Title for the INP breakdown widget.
*/
inpBreakdown: 'INP breakdown',
/**
* @description Accessible label for the reveal button in the document latency widget.
*/
revealDocumentLatency: 'Reveal document latency',
/**
* @description Title for the document latency widget.
*/
documentLatency: 'Document latency',
/**
* @description Accessible label for the reveal button in the DOM size widget.
*/
revealDomSize: 'Reveal DOM size',
/**
* @description Title for the DOM size widget.
*/
domSize: 'DOM size',
/**
* @description Accessible label for the reveal button in the duplicated JavaScript widget.
*/
revealDuplicateJavaScript: 'Reveal duplicated JavaScript',
/**
* @description Title for the duplicated JavaScript widget.
*/
duplicateJavaScript: 'Duplicated JavaScript',
/**
* @description Accessible label for the reveal button in the image delivery widget.
*/
revealImageDelivery: 'Reveal image delivery',
/**
* @description Title for the image delivery widget.
*/
imageDelivery: 'Image delivery',
/**
* @description Accessible label for the reveal button in the font display widget.
*/
revealFontDisplay: 'Reveal font display',
/**
* @description Title for the font display widget.
*/
fontDisplay: 'Font display',
/**
* @description Accessible label for the reveal button in the slow CSS selectors widget.
*/
revealSlowCssSelector: 'Reveal slow CSS selectors',
/**
* @description Title for the slow CSS selectors widget.
*/
slowCssSelector: 'Slow CSS selectors',
/**
* @description Accessible label for the reveal button in the legacy JavaScript widget.
*/
revealLegacyJavaScript: 'Reveal legacy JavaScript',
/**
* @description Title for the legacy JavaScript widget.
*/
legacyJavaScript: 'Legacy JavaScript',
/**
* @description Accessible label for the reveal button in the viewport optimization widget.
*/
revealViewport: 'Reveal viewport optimization',
/**
* @description Title for the viewport optimization widget.
*/
viewport: 'Viewport optimization',
/**
* @description Accessible label for the reveal button in the network request general headers widget.
*/
revealNetworkRequest: 'Reveal network request',
/**
* @description Title for the network request general headers widget.
*/
networkRequest: 'Network request',
/**
* @description Accessible label for the reveal button in the modern HTTP usage widget.
*/
revealModernHttp: 'Reveal modern HTTP usage',
/**
* @description Title for the modern HTTP usage widget.
*/
modernHttp: 'Modern HTTP usage',
/**
* @description Accessible label for the reveal button in the character set declaration widget.
*/
revealCharacterSet: 'Reveal character set declaration',
/**
* @description Title for the character set declaration widget.
*/
characterSet: 'Character set declaration',
/**
* @description Title for the network requests list widget.
*/
networkRequests: 'Network requests',
/**
* @description Accessible label for the reveal button in the network requests list widget.
*/
revealFirstNetworkRequest: 'Reveal first network request in Network panel',
/**
* @description Title for the source files list widget.
*/
inspectedFileNames: 'Inspected file names',
/**
* @description Title for the storage breakdown widget.
*/
storageBreakdown: 'Storage breakdown',
/**
* @description Accessible label for the reveal button in the storage breakdown widget.
*/
revealStorageBreakdown: 'Reveal storage breakdown in Application panel',
} as const;
/**
* Payload and confirmation handler for side-effect operations requiring user approval.
*/
export interface ConfirmSideEffectDialog {
/**
* Human-readable description explaining the pending side-effect action, or null if omitted.
*/
description: string|null;
/**
* Callback invoked when the user resolves the dialog (true to confirm, false to decline).
*/
onAnswer: (result: boolean) => void;
}
/**
* Represents the execution state of an individual agent action step:
* - `in_progress`: The step is actively querying context or parsing stream content.
* - `needs_approval`: The step is awaiting user confirmation for a side-effecting operation.
* - `canceled`: The step was canceled before execution.
* - `completed`: The step completed execution successfully.
*/
export type StepState = {
type: 'in_progress',
}|{type: 'needs_approval', sideEffectDialog: ConfirmSideEffectDialog}|{type: 'canceled'}|{type: 'completed'};
/**
* Represents an individual execution step within an agent response.
*/
export interface Step {
/**
* The current lifecycle state of this step.
*/
state: StepState;
/**
* The agent's intermediate thought or rationale for taking this step.
*/
thought?: string;
/**
* The human-readable title describing the step action.
*/
title?: string;
/**
* Executable JavaScript code generated or executed in this step.
*/
code?: string;
/**
* Raw string output or data returned by code execution.
*/
output?: string;
/**
* Visual widgets rendered alongside this step (e.g. core web vitals, network traces).
*/
widgets?: AiWidget[];
/**
* Context item details gathered or inspected during this step.
*/
contextDetails?: [AiAssistanceModel.AiAgent.ContextDetail, ...AiAssistanceModel.AiAgent.ContextDetail[]];
}
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;
}
/**
* Represents a part of the message that consists of one or more widgets.
* The agent can yield widgets directly as part of its response, separate
* from those returned by a specific tool call (which are encapsulated
* within a StepPart).
*/
export interface WidgetPart {
type: 'widget';
widgets: AiWidget[];
}
export type ModelMessagePart = AnswerPart|StepPart|WidgetPart;
export interface UserChatMessage {
entity: ChatMessageEntity.USER;
text: string;
imageInput?: Host.AidaClient.Part;
id: string;
}
export interface ModelChatMessage {
entity: ChatMessageEntity.MODEL;
parts: ModelMessagePart[];
error?: AiAssistanceModel.AiAgent.ErrorType;
rpcId?: Host.AidaClient.RpcGlobalId;
id: string;
}
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;
onExportClick?: () => 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;
isFirstMessage: boolean;
prompt: string;
canShowFeedbackForm: boolean;
markdownRenderer: MarkdownLitRenderer;
onSuggestionClick: (suggestion: string) => void;
onFeedbackSubmit: (rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void;
onCopyResponseClick: (message: ModelChatMessage) => void;
onExportClick?: () => void;
walkthrough: {
onOpen: (message: ModelChatMessage) => void,
isExpanded: boolean,
onToggle: (isOpen: boolean, message: ModelChatMessage) => void,
isInlined: boolean,
activeSidebarMessage: ModelChatMessage|null,
inlineExpandedMessages: ModelChatMessage[],
};
}
export const DEFAULT_VIEW = (input: ChatMessageViewInput, output: ViewOutput, target: HTMLElement): void => {
const message = input.message;
if (message.entity === ChatMessageEntity.USER) {
const imageInput = message.imageInput && 'inlineData' in message.imageInput ?
renderImageChatMessage(message.imageInput.inlineData) :
Lit.nothing;
const messageClasses = Lit.Directives.classMap({
'chat-message': true,
query: true,
'is-last-message': input.isLastMessage,
'is-first-message': input.isFirstMessage,
});
// clang-format off
Lit.render(html`
<style>${Input.textInputStyles}</style>
<style>${chatMessageStyles}</style>
<div class="user-query-wrapper">
<section class=${messageClasses} jslog=${VisualLogging.section('question')}>
${imageInput}
<div class="message-content">${renderTextAsMarkdown(message.text, input.markdownRenderer)}</div>
</section>
</div>
`, target);
// clang-format on
return;
}
const steps = message.parts.filter(part => part.type === 'step').map(part => part.step);
const messageClasses = Lit.Directives.classMap({
'chat-message': true,
answer: true,
'is-last-message': input.isLastMessage,
'is-first-message': input.isFirstMessage,
});
// clang-format off
Lit.render(html`
<style>${Input.textInputStyles}</style>
<style>${chatMessageStyles}</style>
<section class=${messageClasses} jslog=${VisualLogging.section('answer')}>
${renderWalkthroughUI(input, steps)}
<div class="answer-body-wrapper">
${Lit.Directives.repeat(
message.parts,
(_, index) => index,
(part, index) => {
const isLastPart = index === message.parts.length - 1;
if (part.type === 'answer') {
return html`<p>${renderTextAsMarkdown(part.text, input.markdownRenderer, { animate: !input.isReadOnly && input.isLoading && isLastPart && input.isLastMessage })}</p>`;
}
if (part.type === 'widget') {
return html`${Lit.Directives.until(renderWidgets(part.widgets, {wrapperClass: 'main-widgets-wrapper'}))}`;
}
return Lit.nothing;
},
)}
${renderError(message)}
${input.showActions ? renderActions(input, output) : Lit.nothing}
</div>
${renderSideEffectStepsUI(input, steps)}
</section>
`, 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`<devtools-markdown-view
.data=${{tokens, renderer: markdownRenderer, animationEnabled: animate} as MarkdownView.MarkdownView.MarkdownViewData}
${refFn ? ref(refFn) : Lit.nothing}>
</devtools-markdown-view>`;
// clang-format on
}
export function titleForStep(step: Step): string {
return step.title ?? `${lockedString(UIStringsNotTranslate.investigating)}…`;
}
function renderTitle(step: Step): Lit.LitTemplate {
const paused = step.state.type === 'needs_approval' ?
html`<span class="paused">${lockedString(UIStringsNotTranslate.paused)}: </span>` :
Lit.nothing;
return html`<h3 class="title" aria-label=${titleForStep(step)}>${paused}${titleForStep(step)}</h3>`;
}
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.state.type !== '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`<div class="action-result">
<devtools-code-block
.code=${step.code.trim()}
.codeLang=${'js'}
.displayNotice=${!Boolean(step.output)}
.header=${codeHeadingText}
.showCopyButton=${true}
></devtools-code-block>
</div>` :
Lit.nothing;
const output = step.output ? html`<div class="js-code-output">
<devtools-code-block
.code=${step.output}
.codeLang=${'js'}
.displayNotice=${true}
.header=${lockedString(UIStringsNotTranslate.dataReturned)}
.showCopyButton=${false}
></devtools-code-block>
</div>` :
Lit.nothing;
return html`<div class="step-code">${code}${output}</div>`;
// clang-format on
}
function renderStepDetails({
step,
markdownRenderer,
isLast,
}: {
step: Step,
markdownRenderer: MarkdownLitRenderer,
isLast: boolean,
}): Lit.LitTemplate {
const sideEffects =
isLast && step.state.type === 'needs_approval' ? renderSideEffectConfirmationUi(step) : Lit.nothing;
const thought = step.thought ? html`<p>${renderTextAsMarkdown(step.thought, markdownRenderer)}</p>` : Lit.nothing;
// clang-format off
const contextDetails = step.contextDetails ?
html`${Lit.Directives.repeat(
step.contextDetails,
contextDetail => {
return html`<div class="context-details">
<devtools-code-block
.code=${contextDetail.text}
.codeLang=${contextDetail.codeLang || ''}
.displayNotice=${false}
.header=${contextDetail.title}
.showCopyButton=${true}
></devtools-code-block>
</div>`;
},
)}` : Lit.nothing;
return html`<div class="step-details">
${thought}
${renderStepCode(step)}
${sideEffects}
${contextDetails}
</div>`;
// clang-format on
}
function renderWalkthroughSidebarButton(
input: ChatMessageViewInput,
steps: Step[],
): Lit.LitTemplate {
const {message, walkthrough} = input;
const lastStep = steps.at(-1);
if (walkthrough.isInlined || !lastStep) {
return Lit.nothing;
}
const hasOneStepWithWidget = steps.some(step => step.widgets?.length);
const isExpanded = walkthrough.isExpanded && input.message.id === input.walkthrough.activeSidebarMessage?.id;
const title = isExpanded ? walkthroughCloseTitle({hasWidgets: hasOneStepWithWidget}) : walkthroughTitle({
isLoading: input.isLoading,
hasWidgets: hasOneStepWithWidget,
lastStep,
});
// The button should be tonal when there are widgets, but we only
// want to change it visually at the end once everything has stopped
// loading.
const variant = hasOneStepWithWidget && !input.isLoading ? Buttons.Button.Variant.TONAL : Buttons.Button.Variant.TEXT;
const icon = AiAssistanceModel.AiUtils.getIconName();
const toggleContainerClasses = Lit.Directives.classMap({
'walkthrough-toggle-container': true,
// We only apply the widget styling when loading is complete
'has-widgets': hasOneStepWithWidget && !input.isLoading,
});
const accessibleLabel = getButtonLabel({
isExpanded,
isLoading: input.isLoading,
hasWidgets: hasOneStepWithWidget,
prompt: input.prompt,
stepTitle: titleForStep(lastStep),
});
// clang-format off
return html`
<div class=${toggleContainerClasses}>
${input.isLoading ?
html`<devtools-spinner></devtools-spinner>` :
html`<devtools-icon name=${icon}></devtools-icon>`}
<devtools-button
.variant=${variant}
.size=${Buttons.Button.Size.SMALL}
.title=${lastStep.state.type === 'in_progress' ? titleForStep(lastStep) : title}
.accessibleLabel=${accessibleLabel}
.jslogContext=${walkthrough.isExpanded ? 'ai-hide-walkthrough-sidebar' : 'ai-show-walkthrough-sidebar'}
data-show-walkthrough
@click=${() => {
if (walkthrough.activeSidebarMessage?.id === input.message.id && walkthrough.isExpanded) {
walkthrough.onToggle(false, message as ModelChatMessage);
} 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}<devtools-icon class="chevron" .name=${isExpanded ? 'cross' : 'chevron-right'}></devtools-icon>
</devtools-button>
</div>
`;
// 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. In cases where the
* walkthrough is closed, side-effect steps are rendered inline in the chat.
*/
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;
}
// If the walkthrough is in the sidebar, we render a button into the
// ChatView to open it.
const openWalkThroughSidebarButton =
!input.walkthrough.isInlined ? renderWalkthroughSidebarButton(input, steps) : Lit.nothing;
// A message's walkthrough is considered expanded if the walkthrough is
// open and it is specifically targeting this message. This is necessary
// because the walkthrough state is shared across all messages in the chat.
const isExpanded = input.walkthrough.isInlined ?
input.walkthrough.inlineExpandedMessages.some(m => m.id === input.message.id) :
(input.walkthrough.isExpanded && input.walkthrough.activeSidebarMessage?.id === input.message.id);
// clang-format off
const walkthroughInline = input.walkthrough.isInlined ? html`
<div class="walkthrough-container">
${widget(WalkthroughView, {
message: input.message as ModelChatMessage,
isLoading: input.isLoading && input.isLastMessage,
markdownRenderer: input.markdownRenderer,
isInlined: true,
isExpanded,
prompt: input.prompt,
onToggle: input.walkthrough.onToggle,
onOpen: input.walkthrough.onOpen,
})}
</div>
` : Lit.nothing;
return html`
${openWalkThroughSidebarButton}
${walkthroughInline}
`;
// clang-format on
}
function renderSideEffectStepsUI(input: ChatMessageViewInput, steps: Step[]): Lit.LitTemplate {
const sideEffectSteps = steps.filter(s => s.state.type === 'needs_approval' || s.state.type === 'canceled');
if (sideEffectSteps.length === 0) {
return Lit.nothing;
}
// clang-format off
return html`
${sideEffectSteps.map(step => html`
<div class="side-effect-container">
${renderStep({
step,
markdownRenderer: input.markdownRenderer,
isLast: true,
})}
</div> `)}
`;
// clang-format on
}
function renderStepBadge({step, isLast}: {
step: Step,
isLast: boolean,
}): Lit.LitTemplate {
if (isLast && step.state.type === 'in_progress') {
return html`<devtools-spinner aria-label=${lockedString(UIStringsNotTranslate.inProgress)}></devtools-spinner>`;
}
let iconName = 'checkmark';
let ariaLabel: string|undefined = lockedString(UIStringsNotTranslate.completed);
let role: 'button'|undefined = 'button';
if (step.state.type === 'needs_approval') {
if (!isLast) {
console.error('A step in needs_approval state must be the last step.');
}
role = undefined;
ariaLabel = lockedString(UIStringsNotTranslate.paused);
iconName = 'pause-circle';
} else if (step.state.type === 'canceled') {
ariaLabel = lockedString(UIStringsNotTranslate.aborted);
iconName = 'cross';
}
return html`<devtools-icon
class="indicator"
role=${ifDefined(role)}
aria-label=${ifDefined(ariaLabel)}
.name=${iconName}
></devtools-icon>`;
}
export function renderStep({step, markdownRenderer, isLast}: {
step: Step,
markdownRenderer: MarkdownLitRenderer,
isLast: boolean,
}): Lit.LitTemplate {
const stepClasses = Lit.Directives.classMap({
step: true,
empty: !step.thought && !step.code && !step.contextDetails && step.state.type !== 'needs_approval',
paused: step.state.type === 'needs_approval',
canceled: step.state.type === 'canceled',
});
// clang-format off
return html`
<details class=${stepClasses}
jslog=${VisualLogging.expand('step').track({click: true})}
.open=${step.state.type === 'needs_approval'}>
<summary>
<div class="summary">
${renderStepBadge({ step, isLast })}
${renderTitle(step)}
<devtools-icon
class="arrow"
name="chevron-down"
></devtools-icon>
</div>
</summary>
${renderStepDetails({step, markdownRenderer, isLast})}
</details>
${Lit.Directives.until(renderWidgets(step.widgets, {wrapperClass: 'step-widgets-wrapper'}))}
`;
// clang-format on
}
interface WidgetMakerResponse {
// Can be null if the widget is only used to add the Reveal CTA.
renderedWidget: Lit.LitTemplate|null;
revealable: unknown;
customRevealTitle?: Platform.UIString.LocalizedString;
// Can be null if the widget is only used to add the Reveal CTA.
title: Lit.LitTemplate|Platform.UIString.LocalizedString|null;
jslogContext?: string;
accessibleRevealLabel: Platform.UIString.LocalizedString;
}
const nodeCache = new Map<Protocol.DOM.BackendNodeId, SDK.DOMModel.DOMNode>();
async function resolveNode(backendNodeId: Protocol.DOM.BackendNodeId): Promise<SDK.DOMModel.DOMNode|null> {
const cachedNode = nodeCache.get(backendNodeId);
if (cachedNode) {
return cachedNode;
}
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!target) {
return null;
}
const node = new SDK.DOMModel.DeferredDOMNode(target, backendNodeId);
const resolved = await node.resolvePromise();
if (resolved) {
nodeCache.set(backendNodeId, resolved);
}
return resolved;
}
async function makeStorageBreakdownWidget(widgetData: StorageBreakdownAiWidget): Promise<WidgetMakerResponse|null> {
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!target) {
return null;
}
const breakdown = widgetData.data.usageBreakdown;
const total = breakdown.reduce((sum, item) => sum + item.bytes, 0);
const slices = breakdown.map(item => {
const color = Application.StorageView.storagePieColors.get(item.storageType as Protocol.Storage.StorageType) ||
'rgb(180, 180, 180)';
const title = Application.StorageView.StorageView.getStorageTypeNameForWidget(item.storageType);
return {
value: item.bytes,
color,
title,
};
});
const chartData: PerfUI.PieChart.PieChartData = {
chartName: lockedString(UIStringsNotTranslate.storageBreakdown),
size: 110,
formatter: val => AiAssistanceModel.UnitFormatters.bytes(val),
showLegend: true,
total,
slices,
};
const renderedWidget = html`
<div class="storage-breakdown-widget">
<devtools-perf-piechart .data=${chartData}></devtools-perf-piechart>
</div>
`;
return {
renderedWidget,
title: lockedString(UIStringsNotTranslate.storageBreakdown),
revealable: new Application.StorageView.StorageRevealable(target),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealStorageBreakdown),
jslogContext: 'storage-breakdown-widget',
};
}
async function makeComputedStyleWidget(widgetData: ComputedStyleAiWidget): Promise<WidgetMakerResponse|null> {
const domNodeForId = await resolveNode(widgetData.data.backendNodeId);
if (!domNodeForId) {
return null;
}
const styles = new ComputedStyle.ComputedStyleModel.ComputedStyle(domNodeForId, widgetData.data.computedStyles);
let filterText: RegExp|null = null;
try {
filterText = new RegExp(widgetData.data.properties.join('|'), 'i');
} catch {
// If the AI provides an invalid regex (e.g. "*"), we don't want to crash.
// We can just skip the widget in this case.
return null;
}
// clang-format off
const renderedWidget = html`<devtools-widget
class="computed-styles-widget" ${widget(Elements.ComputedStyleWidget.ComputedStyleWidget, {
nodeStyle: styles,
matchedStyles: widgetData.data.matchedCascade,
// This disables showing the nested traces and detailed information in the widget.
propertyTraces: null,
allowUserControl: false,
filterText,
enableNarrowViewResizing: false,
})}></devtools-widget>`;
// clang-format on
return {
renderedWidget,
revealable: new Elements.ElementsPanel.NodeComputedStyles(domNodeForId),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealComputedStyles),
// clang-format off
title: html`
<span class="computed-style-title-wrapper">
<span class="computed-style-title-prefix">Computed styles</span>
<span class="style-class-wrapper">
(<devtools-widget
${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {
node: domNodeForId,
})}
></devtools-widget>)
</span>
</span>`,
// clang-format on
jslogContext: 'computed-styles',
};
}
async function makeCoreWebVitalsWidget(widgetData: CoreVitalsAiWidget): Promise<WidgetMakerResponse|null> {
// clang-format off
const renderedWidget = html`<devtools-widget class="core-vitals-widget" ${widget(TimelineComponents.CWVMetrics.CWVMetrics, {data: widgetData.data, skipBottomBorder: true})}>
</devtools-widget>`;
// clang-format on
return {
renderedWidget,
revealable: new TimelineUtils.Helpers.RevealableCoreVitals(widgetData.data.insightSetKey),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealCoreWebVitals),
title: lockedString(UIStringsNotTranslate.coreVitals),
jslogContext: 'core-web-vitals',
};
}
async function makeStylePropertiesWidget(widgetData: StylePropertiesAiWidget): Promise<WidgetMakerResponse|null> {
const domNodeForId = await resolveNode(widgetData.data.backendNodeId);
if (!domNodeForId) {
return null;
}
let filter: RegExp|null = null;
try {
filter = widgetData.data.selector ? new RegExp(widgetData.data.selector) : null;
} catch {
// If the AI provides an invalid regex (e.g. "*"), we don't want to crash.
// We can just skip the widget in this case.
return null;
}
// clang-format off
const renderedWidget = html`<devtools-widget
class="styling-preview-widget"
${widget(Elements.StandaloneStylesContainer.StandaloneStylesContainer, {
domNode: domNodeForId,
filter,
})}>
</devtools-widget>`;
// clang-format on
return {
renderedWidget,
revealable: domNodeForId,
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealStyleProperties),
title: html`<devtools-widget
${widget(PanelsCommon.DOMLinkifier.DOMNodeLink, {
node: domNodeForId,
})}
></devtools-widget>`,
jslogContext: 'standalone-styles',
};
}
const INSIGHT_METADATA: Record<string, {
component: new () => BaseInsightComponent<Trace.Insights.Types.InsightModel>,
accessibleLabel: string,
title: string,
jslog: string,
}> = {
[Trace.Insights.Types.InsightKeys.LCP_BREAKDOWN]: {
component: TimelineInsights.LCPBreakdown.LCPBreakdown,
accessibleLabel: UIStringsNotTranslate.revealLcpBreakdown,
title: UIStringsNotTranslate.lcpBreakdown,
jslog: 'lcp-breakdown-widget',
},
[Trace.Insights.Types.InsightKeys.RENDER_BLOCKING]: {
component: TimelineInsights.RenderBlocking.RenderBlocking,
accessibleLabel: UIStringsNotTranslate.revealRenderBlockingBreakdown,
title: UIStringsNotTranslate.renderBlockingBreakdown,
jslog: 'render-blocking-widget',
},
[Trace.Insights.Types.InsightKeys.LCP_DISCOVERY]: {
component: TimelineInsights.LCPDiscovery.LCPDiscovery,
accessibleLabel: UIStringsNotTranslate.revealLcpDiscovery,
title: UIStringsNotTranslate.lcpDiscovery,
jslog: 'lcp-discovery-widget',
},
[Trace.Insights.Types.InsightKeys.CLS_CULPRITS]: {
component: TimelineInsights.CLSCulprits.CLSCulprits,
accessibleLabel: UIStringsNotTranslate.revealClsCulprits,
title: UIStringsNotTranslate.clsCulprits,
jslog: 'cls-culprits-widget',
},
[Trace.Insights.Types.InsightKeys.NETWORK_DEPENDENCY_TREE]: {
component: TimelineInsights.NetworkDependencyTree.NetworkDependencyTree,
accessibleLabel: UIStringsNotTranslate.revealNetworkDependencyTree,
title: UIStringsNotTranslate.networkDependencyTree,
jslog: 'network-dependency-tree-widget',
},
[Trace.Insights.Types.InsightKeys.THIRD_PARTIES]: {
component: TimelineInsights.ThirdParties.ThirdParties,
accessibleLabel: UIStringsNotTranslate.revealThirdParties,
title: UIStringsNotTranslate.thirdParties,
jslog: 'third-parties-widget',
},
[Trace.Insights.Types.InsightKeys.FORCED_REFLOW]: {
component: TimelineInsights.ForcedReflow.ForcedReflow,
accessibleLabel: UIStringsNotTranslate.revealForcedReflow,
title: UIStringsNotTranslate.forcedReflow,
jslog: 'forced-reflow-widget',
},
[Trace.Insights.Types.InsightKeys.CACHE]: {
component: TimelineInsights.Cache.Cache,
accessibleLabel: UIStringsNotTranslate.revealCache,
title: UIStringsNotTranslate.cache,
jslog: 'cache-widget',
},
[Trace.Insights.Types.InsightKeys.INP_BREAKDOWN]: {
component: TimelineInsights.INPBreakdown.INPBreakdown,
accessibleLabel: UIStringsNotTranslate.revealInpBreakdown,
title: UIStringsNotTranslate.inpBreakdown,
jslog: 'inp-breakdown-widget',
},
[Trace.Insights.Types.InsightKeys.DOCUMENT_LATENCY]: {
component: TimelineInsights.DocumentLatency.DocumentLatency,
accessibleLabel: UIStringsNotTranslate.revealDocumentLatency,
title: UIStringsNotTranslate.documentLatency,
jslog: 'document-latency-widget',
},
[Trace.Insights.Types.InsightKeys.DOM_SIZE]: {
component: TimelineInsights.DOMSize.DOMSize,
accessibleLabel: UIStringsNotTranslate.revealDomSize,
title: UIStringsNotTranslate.domSize,
jslog: 'dom-size-widget',
},
[Trace.Insights.Types.InsightKeys.DUPLICATE_JAVASCRIPT]: {
component: TimelineInsights.DuplicatedJavaScript.DuplicatedJavaScript,
accessibleLabel: UIStringsNotTranslate.revealDuplicateJavaScript,
title: UIStringsNotTranslate.duplicateJavaScript,
jslog: 'duplicate-javascript-widget',
},
[Trace.Insights.Types.InsightKeys.IMAGE_DELIVERY]: {
component: TimelineInsights.ImageDelivery.ImageDelivery,
accessibleLabel: UIStringsNotTranslate.revealImageDelivery,
title: UIStringsNotTranslate.imageDelivery,
jslog: 'image-delivery-widget',
},
[Trace.Insights.Types.InsightKeys.FONT_DISPLAY]: {
component: TimelineInsights.FontDisplay.FontDisplay,
accessibleLabel: UIStringsNotTranslate.revealFontDisplay,
title: UIStringsNotTranslate.fontDisplay,
jslog: 'font-display-widget',
},
[Trace.Insights.Types.InsightKeys.SLOW_CSS_SELECTOR]: {
component: TimelineInsights.SlowCSSSelector.SlowCSSSelector,
accessibleLabel: UIStringsNotTranslate.revealSlowCssSelector,
title: UIStringsNotTranslate.slowCssSelector,
jslog: 'slow-css-selector-widget',
},
[Trace.Insights.Types.InsightKeys.LEGACY_JAVASCRIPT]: {
component: TimelineInsights.LegacyJavaScript.LegacyJavaScript,
accessibleLabel: UIStringsNotTranslate.revealLegacyJavaScript,
title: UIStringsNotTranslate.legacyJavaScript,
jslog: 'legacy-javascript-widget',
},
[Trace.Insights.Types.InsightKeys.VIEWPORT]: {
component: TimelineInsights.Viewport.Viewport,
accessibleLabel: UIStringsNotTranslate.revealViewport,
title: UIStringsNotTranslate.viewport,
jslog: 'viewport-widget',
},
[Trace.Insights.Types.InsightKeys.MODERN_HTTP]: {
component: TimelineInsights.ModernHTTP.ModernHTTP,
accessibleLabel: UIStringsNotTranslate.revealModernHttp,
title: UIStringsNotTranslate.modernHttp,
jslog: 'modern-http-widget',
},
[Trace.Insights.Types.InsightKeys.CHARACTER_SET]: {
component: TimelineInsights.CharacterSet.CharacterSet,
accessibleLabel: UIStringsNotTranslate.revealCharacterSet,
title: UIStringsNotTranslate.characterSet,
jslog: 'character-set-widget',
},
};
function renderInsightWidget<T extends Trace.Insights.Types.InsightModel>(
component: new () => BaseInsightComponent<T>, insight: T, jslog: string, accessibleLabel: string, title: string,
bounds?: Trace.Types.Timing.TraceWindowMicro): WidgetMakerResponse {
const renderedWidget = html`<devtools-widget
class=${jslog}
${widget(component, {
model: insight,
minimal: true,
bounds: bounds ?? null,
})}></devtools-widget>`;
return {
renderedWidget,
revealable: new TimelineUtils.Helpers.RevealableInsight(insight),
accessibleRevealLabel: lockedString(accessibleLabel),
title: lockedString(title),
jslogContext: jslog,
};
}
async function makePerfInsightWidget(widgetData: PerfInsightAiWidget): Promise<WidgetMakerResponse|null> {
const insightKey = widgetData.data.insight;
const insight = widgetData.data.insightData;
const meta = INSIGHT_METADATA[insightKey];
if (!meta) {
return null;
}
let bounds;
if (insightKey === Trace.Insights.Types.InsightKeys.CLS_CULPRITS) {
const traceBounds = TraceBounds.TraceBounds.BoundsManager.instance().state()?.micro.entireTraceBounds;
if (!traceBounds) {
return null;
}
bounds = traceBounds;
}
return renderInsightWidget(meta.component, insight, meta.jslog, meta.accessibleLabel, meta.title, bounds);
}
async function makeBottomUpTimelineTreeWidget(widgetData: BottomUpTreeAiWidget): Promise<WidgetMakerResponse|null> {
const bottomUpRootNode = AiAssistanceModel.AIQueries.AIQueries.mainThreadActivityBottomUp(
widgetData.data.bounds, widgetData.data.parsedTrace);
if (!bottomUpRootNode) {
return null;
}
const events = bottomUpRootNode.events;
const startTime = Trace.Helpers.Timing.microToMilli(widgetData.data.bounds.min);
const endTime = Trace.Helpers.Timing.microToMilli(widgetData.data.bounds.max);
const renderedWidget = html`<devtools-widget
class="bottom-up-timeline-tree-widget"
${widget(Timeline.TimelineTreeView.BottomUpTimelineTreeView, {
selectedEvents: events,
parsedTrace: widgetData.data.parsedTrace,
startTime,
endTime,
compactMode: true,
maxLinkLength: 15,
maxRows: 10,
})}></devtools-widget>`;
return {
renderedWidget,
revealable: new TimelineUtils.Helpers.RevealableBottomUpProfile(widgetData.data.bounds),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealBottomUpTree),
title: lockedString(UIStringsNotTranslate.bottomUpTree),
jslogContext: 'bottom-up',
};
}
function renderWidgetResponse(response: WidgetMakerResponse|null): Lit.LitTemplate {
if (response === null) {
return Lit.nothing;
}
function onReveal(): void {
if (response === null) {
return;
}
Common.Revealer.reveal(response?.revealable).catch((error: Error) => {
if (!error.message) {
return;
}
Snackbars.Snackbar.Snackbar.show({message: error.message});
});
}
const classes = Lit.Directives.classMap({
'widget-and-revealer-container': true,
'revealer-only': response.renderedWidget === null,
});
const revealButton = html`
<devtools-button class="widget-reveal-button"
.variant=${Buttons.Button.Variant.TEXT}
.accessibleLabel=${response.accessibleRevealLabel}
.jslogContext=${'reveal'}
@click=${onReveal}
>
${response.customRevealTitle ?? lockedString(UIStringsNotTranslate.reveal)}
<devtools-icon name='tab-move'></devtools-icon>
</devtools-button>
`;
// clang-format off
return html`
<div class=${classes} jslog=${ifDefined(response.jslogContext ? VisualLogging.section(response.jslogContext) : undefined)}>
${response.title ? html`
<div class="widget-header">
<h4 class="widget-name">${response.title}</h4>
<div class="widget-reveal-container">
${revealButton}
</div>
</div>
` : Lit.nothing}
${response.renderedWidget ? html`
<div class="widget-content-container">
${response.renderedWidget}
</div>` : Lit.nothing
}
${!response.title ? html`
<div class="widget-reveal-container">
${revealButton}
</div>
` : Lit.nothing}
</div>
`;
// clang-format on
}
async function makePerformanceTraceWidget(widgetData: PerformanceTraceAiWidget): Promise<WidgetMakerResponse|null> {
const customRevealTitle = lockedString(UIStringsNotTranslate.revealTrace);
return {
renderedWidget: null,
title: null,
revealable: new Timeline.TimelinePanel.ParsedTraceRevealable(widgetData.data.parsedTrace),
customRevealTitle,
accessibleRevealLabel: customRevealTitle,
jslogContext: 'performance-trace',
};
}
async function makeSourceFileWidget(widgetData: SourceFileAiWidget): Promise<WidgetMakerResponse|null> {
const file = widgetData.data.uiSourceCode;
const customRevealTitle = i18n.i18n.lockedString(`Show ${file.name()}`);
return {
renderedWidget: null,
title: null,
revealable: file,
customRevealTitle,
accessibleRevealLabel: customRevealTitle,
jslogContext: 'source-file-widget',
};
}
async function makeSourceCodeWidget(widgetData: SourceCodeAiWidget): Promise<WidgetMakerResponse|null> {
const url = widgetData.data.url;
const filename = url.split('/').pop() || url;
const line = widgetData.data.line;
const column = widgetData.data.column;
// If both line and column numbers are provided, we represent a specific function execution,
// so the widget title is formatted as 'filename:line:column' (matching the Sources Panel style).
// Otherwise, if line and column are not provided, we are viewing the entire file contents,
// so the header simply displays 'filename'.
const header = line !== undefined && column !== undefined ? `${filename}:${line}:${column}` : filename;
const uiSourceCode =
Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url as Platform.DevToolsPath.UrlString);
const lastDotIndex = filename.lastIndexOf('.');
const fileExtension = lastDotIndex !== -1 ? filename.substring(lastDotIndex + 1) : '';
let code = widgetData.data.code;
if (TextUtils.TextUtils.isMinified(code)) {
const canonicalMimeType = uiSourceCode?.contentType().canonicalMimeType() || 'text/javascript';
const formatted = await Formatter.ScriptFormatter.formatScriptContent(Common.Settings.Settings.instance(),
canonicalMimeType, code, ' ');
code = formatted.formattedContent;
}
const renderedWidget = html`
<devtools-code-block
class="source-code-widget"
.displayLimit=${20}
.code=${code}
.codeLang=${fileExtension}
.displayToolbar=${false}
.displayNotice=${false}
></devtools-code-block>
`;
return {
renderedWidget,
title: lockedString(header),
revealable: uiSourceCode,
accessibleRevealLabel: i18n.i18n.lockedString(`Show ${filename} in Sources`),
jslogContext: 'source-code-widget',
};
}
function renderFileRevealButton(
file: Workspace.UISourceCode.UISourceCode,
collapsed: boolean,
): Lit.TemplateResult {
const onReveal = (): void => {
void Common.Revealer.reveal(file);
};
const accessibleLabel = i18n.i18n.lockedString(`Show ${file.fullDisplayName()}`);
const className = `widget-reveal-button ${collapsed ? 'collapsed-file' : 'visible-file'}`;
return html`
<devtools-button class=${className}
.variant=${Buttons.Button.Variant.TEXT}
.accessibleLabel=${accessibleLabel}
.jslogContext=${'reveal'}
@click=${onReveal}>
${file.fullDisplayName()}
<devtools-icon name='tab-move'></devtools-icon>
</devtools-button>
`;
}
async function makeSourceFilesListWidget(widgetData: SourceFilesListAiWidget): Promise<WidgetMakerResponse|null> {
const files = widgetData.data.uiSourceCodes;
if (files.length === 0) {
return null;
}
// If there are more than 10 files, only show the first 10, and hide the rest unless "Show all" is clicked.
// clang-format off
const renderedWidget = html`
<div class="source-files-widget">
${files.slice(0, 10).map(file => renderFileRevealButton(file, /* collapsed */ false))}
${files.length > 10 ? html`
<details class="source-files-details">
<summary class="show-more-summary">${i18n.i18n.lockedString(`Show all ${files.length} files`)}</summary>
${files.slice(10).map(file => renderFileRevealButton(file, /* collapsed */ true))}
</details> ` : Lit.nothing}
</div>`;
// clang-format on
const title = lockedString(UIStringsNotTranslate.inspectedFileNames);
return {
renderedWidget,
title,
revealable: files[0],
accessibleRevealLabel: i18n.i18n.lockedString('Reveal first file in Sources panel'),
jslogContext: 'source-files-list-widget',
};
}
const expandedNetworkRequestsWidgets = new WeakSet<NetworkRequestsListAiWidget>();
// A widget with a table of the list of network requests sent to the agent.
// Only show 15 requests maximum in collapsed version. The rest of the requests
// will be hidden unless the user clicks "Show all".
async function makeNetworkRequestsListWidget(widgetData: NetworkRequestsListAiWidget):
Promise<WidgetMakerResponse|null> {
const requests = widgetData.data.requests;
if (requests.length === 0) {
return null;
}
const isExpanded = expandedNetworkRequestsWidgets.has(widgetData);
// We only want just expanded widget to be expanded, if the user closed and reopened the walkthrought, the widget should be collapsed again.
// Therefore, after rendering the widget, we remove the widget from the set of expanded widgets so that it is collapsed on next render.
if (isExpanded) {
expandedNetworkRequestsWidgets.delete(widgetData);
}
const displayedRequests = isExpanded ? requests : requests.slice(0, 15);
// The table contains same fields as the ones sent to the agent.
// clang-format off
const renderedWidget = html`
<div class="network-requests-widget">
<devtools-data-grid striped inline>
<table>
<tr>
<th id="name" weight="4">${i18n.i18n.lockedString('Name')}</th>
<th id="status" weight="1">${i18n.i18n.lockedString('Status')}</th>
<th id="size" weight="1">${i18n.i18n.lockedString('Size')}</th>
<th id="time" weight="1">${i18n.i18n.lockedString('Time')}</th>
</tr>
${displayedRequests.map(request => html`
<tr>
<td>${request.name()}</td>
<td>${request.statusCode}</td>
<td>${i18n.ByteUtilities.formatBytesToKb(request.transferSize)}</td>
<td>${i18n.TimeUtilities.secondsToString(request.duration)}</td>
</tr>
`)}
</table>
</devtools-data-grid>
${!isExpanded && requests.length > 15 ? html`
<div class="show-all-container">
<button class="show-all-widget-requests-button text-button"
jslog=${VisualLogging.action('show-all-widget-requests-button').track({click: true})}
@click=${(e: Event) => {
expandedNetworkRequestsWidgets.add(widgetData);
const widgetEl = (e.target as HTMLElement).closest('.widget');
if (widgetEl) {
const widget = UI.Widget.Widget.get(widgetEl) as ChatMessage;
if (widget && widget.performUpdate) {
void widget.performUpdate();
}
}
}}>
${i18n.i18n.lockedString(`Show all ${requests.length} network requests`)}
</button>
</div>
` : Lit.nothing}
</div>
`;
// clang-format on
return {
renderedWidget,
title: lockedString(UIStringsNotTranslate.networkRequests),
revealable: requests[0],
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealFirstNetworkRequest),
jslogContext: 'network-requests-list-widget',
};
}
function renderNetworkRequestPreview(networkRequest: NonNullable<DomTreeAiWidget['data']['networkRequest']>):
Lit.TemplateResult {
const filename = networkRequest.url.split('/').pop() || networkRequest.url;
const size = i18n.ByteUtilities.bytesToString(networkRequest.size);
const resourceType = Common.ResourceType.resourceTypes[networkRequest.resourceType];
const {iconName, color} = PanelUtils.iconDataForResourceType(resourceType);
// This can be `null` if the image is too large to have a preview generated.
// And we can't fallback to the actual URL due to CSP restrictions.
const imageUrl = networkRequest.imageContent?.asImagePreviewUrl();
// clang-format off
return html`
<div class="network-request-preview">
<div class="network-request-header">
<div class="network-request-icon">
${resourceType.isImage() && imageUrl ? // only try to render the image if we have a preview URL, else fallback to a coloured square.
html`<img src=${imageUrl} alt=${filename} />` :
html`<devtools-icon name=${iconName} style=${Lit.Directives.styleMap({ color: color ?? '' })}></devtools-icon>`}
</div>
<div class="network-request-details">
<div class="network-request-name" title=${networkRequest.url}>${filename}</div>
<div class="network-request-size">${size}</div>
</div>
</div>
</div>
`;
// clang-format on
}
async function makeDomTreeWidget(widgetData: DomTreeAiWidget): Promise<WidgetMakerResponse|null> {
const root = widgetData.data.root;
if (!(root instanceof SDK.DOMModel.DOMNodeSnapshot)) {
return null;
}
const networkRequest = widgetData.data.networkRequest;
// clang-format off
const renderedWidget = html`
${networkRequest ? renderNetworkRequestPreview(networkRequest) : Lit.nothing}
<devtools-widget class="dom-tree-widget" ${widget(Elements.ElementsTreeOutline.DOMTreeWidget, {
maxTreeDepth: 2,
enableContextMenu: false,
showComments: false,
showAIButton: false,
disableEdits: true,
expandRoot: true,
rootDOMNode: root,
visibleWidth: 400,
wrap: true,
maxRows: 10,
})}></devtools-widget>
`;
// clang-format on
return {
renderedWidget,
revealable: new SDK.DOMModel.DeferredDOMNode(root.domModel().target(), root.backendNodeId()),
accessibleRevealLabel: widgetData.data.accessibleRevealLabel,
title: widgetData.data.title,
jslogContext: 'dom-snapshot',
};
}
/**
* Renders AI-defined UI widgets.
* When a ModelChatMessage contains a WidgetPart, or a Step has widgets,
* the ChatMessage component iterates through the \`widgets\` array.
* For each widget, it determines the appropriate rendering logic based on
* the \`widgetData.name\`.
*
* Currently, 'COMPUTED_STYLES', 'CORE_VITALS' and 'STYLE_PROPERTIES' widgets are supported.
* For these, the corresponding \`make...Widget\` functions are called to construct the necessary
* data and configuration for the UI components. The widget is then rendered using the
* \`<devtools-widget>\` 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.
*/
/**
* Generates a deterministic unique identifier for a given AiWidget based on
* its name and identifying data. This signature is used for widget deduplication.
*/
export function getWidgetSignature(widget: AiWidget): string {
switch (widget.name) {
case 'COMPUTED_STYLES':
return `${widget.name}:${widget.data.backendNodeId}`;
case 'CORE_VITALS':
return `${widget.name}:${widget.data.insightSetKey}`;
case 'STYLE_PROPERTIES':
return `${widget.name}:${widget.data.backendNodeId}:${widget.data.selector ?? ''}`;
case 'DOM_TREE':
return `${widget.name}:${widget.data.root.backendNodeId()}`;
case 'PERFORMANCE_TRACE':
return `${widget.name}`;
case 'PERF_INSIGHT':
return `${widget.name}:${widget.data.insight}:${widget.data.insightData.insightKey}:${
widget.data.insightData.navigation?.args?.data?.navigationId ?? 'no-nav-id'}`;
case 'TIMELINE_RANGE_SUMMARY':
return `${widget.name}:${widget.data.track}:${widget.data.bounds.min}-${widget.data.bounds.max}`;
case 'BOTTOM_UP_TREE':
return `${widget.name}:${widget.data.bounds.min}-${widget.data.bounds.max}`;
case 'NETWORK_TRACK':
return `${widget.name}:${widget.data.bounds.min}-${widget.data.bounds.max}`;
case 'SOURCE_FILE':
return `${widget.name}:${widget.data.uiSourceCode.url()}`;
case 'SOURCE_FILES_LIST':
return `${widget.name}:${widget.data.uiSourceCodes.map(f => f.url()).join(',')}`;
case 'LIGHTHOUSE_REPORT':
return `${widget.name}:${widget.data.report.fetchTime}`;
case 'TIMELINE_EVENT_SUMMARY':
return `${widget.name}:${widget.data.event.ts}:${widget.data.event.name}`;
case 'NETWORK_REQUEST_GENERAL_HEADERS':
return `${widget.name}:${widget.data.request.requestId()}`;
case 'SOURCE_CODE':
return `${widget.name}:${widget.data.url}:${widget.data.line ?? ''}:${widget.data.column ?? ''}`;
case 'NETWORK_REQUESTS_LIST':
return `${widget.name}:${widget.data.requests.map(r => r.requestId()).join(',')}`;
case 'STORAGE_BREAKDOWN':
return `${widget.name}:${widget.data.totalUsageBytes}:${
widget.data.usageBreakdown.map(e => `${e.storageType}_${e.bytes}`).join(',')}`;
default:
Platform.assertNever(widget, 'Unknown AiWidget name');
}
}
/**
* Returns a new ModelChatMessage where widgets have been deduplicated
* across all parts and steps of the message. The first occurrence of each
* unique widget (determined by its signature) is preserved.
*/
export function getDeduplicatedWidgetsMessage(message: ModelChatMessage): ModelChatMessage {
const seenWidgets = new Set<string>();
const filterWidgets = (widgets: AiWidget[]): AiWidget[] => {
return widgets.filter(widget => {
const signature = getWidgetSignature(widget);
if (seenWidgets.has(signature)) {
return false;
}
seenWidgets.add(signature);
return true;
});
};
const deduplicatedParts = message.parts.map(part => {
if (part.type === 'widget') {
return {
...part,
widgets: filterWidgets(part.widgets),
};
}
if (part.type === 'step' && part.step.widgets) {
return {
...part,
step: {
...part.step,
widgets: filterWidgets(part.step.widgets),
},
};
}
return part;
});
return {
...message,
parts: deduplicatedParts,
};
}
async function renderWidgets(
widgets: AiWidget[]|undefined, options: {wrapperClass?: string} = {}): Promise<Lit.LitTemplate> {
if (!widgets || widgets.length === 0) {
return Lit.nothing;
}
const ui = await Promise.all(widgets.map(async widgetData => {
let response: WidgetMakerResponse|null = null;
switch (widgetData.name) {
case 'COMPUTED_STYLES':
response = await makeComputedStyleWidget(widgetData);
break;
case 'CORE_VITALS':
response = await makeCoreWebVitalsWidget(widgetData);
break;
case 'STYLE_PROPERTIES':
response = await makeStylePropertiesWidget(widgetData);
break;
case 'DOM_TREE':
response = await makeDomTreeWidget(widgetData);
break;
case 'PERFORMANCE_TRACE':
response = await makePerformanceTraceWidget(widgetData);
break;
case 'PERF_INSIGHT':
response = await makePerfInsightWidget(widgetData);
break;
case 'TIMELINE_RANGE_SUMMARY':
response = await makeTimelineRangeSummaryWidget(widgetData);
break;
case 'BOTTOM_UP_TREE':
response = await makeBottomUpTimelineTreeWidget(widgetData);
break;
case 'NETWORK_TRACK':
response = await makeNetworkTrackWidget(widgetData);
break;
case 'SOURCE_FILE':
response = await makeSourceFileWidget(widgetData);
break;
case 'SOURCE_FILES_LIST':
response = await makeSourceFilesListWidget(widgetData);
break;
case 'NETWORK_REQUESTS_LIST':
response = await makeNetworkRequestsListWidget(widgetData);
break;
case 'LIGHTHOUSE_REPORT':
response = await makeLighthouseReportWidget(widgetData);
break;
case 'TIMELINE_EVENT_SUMMARY':
response = await makeTimelineEventSummaryWidget(widgetData);
break;
case 'NETWORK_REQUEST_GENERAL_HEADERS':
response = await makeNetworkRequestGeneralHeadersWidget(widgetData);
break;
case 'SOURCE_CODE':
response = await makeSourceCodeWidget(widgetData);
break;
case 'STORAGE_BREAKDOWN':
response = await makeStorageBreakdownWidget(widgetData);
break;
default:
Platform.assertNever(widgetData, 'Unknown AiWidget name');
}
return renderWidgetResponse(response);
}));
// Omit the wrapper element entirely if every widget maker resolved to Lit.nothing
// so empty container elements do not occupy vertical layout space in the chat stream.
const renderedItems = ui.filter(item => item !== Lit.nothing);
if (renderedItems.length === 0) {
return Lit.nothing;
}
if (options.wrapperClass) {
return html`<div class=${options.wrapperClass}>${renderedItems}</div>`;
}
return html`${renderedItems}`;
}
function renderSideEffectConfirmationUi(step: Step): Lit.LitTemplate {
if (step.state.type !== 'needs_approval') {
return Lit.nothing;
}
const dialog = step.state.sideEffectDialog;
// clang-format off
return html`<div
class="side-effect-confirmation"
jslog=${VisualLogging.section('side-effect-confirmation')}
>
${dialog.description ? html`<p>${dialog.description}</p>` : Lit.nothing}
<div class="side-effect-buttons-container">
<devtools-button
.data=${
{
variant: Buttons.Button.Variant.OUTLINED,
jslogContext: 'decline-execute-code',
} as Buttons.Button.ButtonData
}
@click=${() => dialog.onAnswer(false)}
>${lockedString(
UIStringsNotTranslate.declineActionRequestApproval,
)}</devtools-button>
<devtools-button
.data=${
{
variant: Buttons.Button.Variant.PRIMARY,
jslogContext: 'accept-execute-code',
iconName: 'play',
} as Buttons.Button.ButtonData
}
@click=${() => dialog.onAnswer(true)}
>${
lockedString(UIStringsNotTranslate.confirmActionRequestApproval)
}</devtools-button>
</div>
</div>`;
// 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.QUOTA:
errorMessage = UIStringsNotTranslate.quotaError;
break;
case AiAssistanceModel.AiAgent.ErrorType.MAX_STEPS:
errorMessage = UIStringsNotTranslate.maxStepsError;
break;
case AiAssistanceModel.AiAgent.ErrorType.CROSS_ORIGIN:
errorMessage = UIStringsNotTranslate.crossOriginError;
break;
case AiAssistanceModel.AiAgent.ErrorType.PAYLOAD_TOO_LARGE:
errorMessage = UIStringsNotTranslate.payloadTooLargeError;
break;
case AiAssistanceModel.AiAgent.ErrorType.ABORT:
return html`<p class="aborted" jslog=${VisualLogging.section('aborted')}>${
lockedString(UIStringsNotTranslate.stoppedResponse)}</p>`;
}
return html`<p class="error" jslog=${VisualLogging.section('error')}>${lockedString(errorMessage)}</p>`;
}
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`<div class="unavailable-image" title=${UIStringsNotTranslate.imageUnavailable}>
<devtools-icon name='file-image'></devtools-icon>
</div>`;
// clang-format on
}
const imageUrl = `data:${inlineData.mimeType};base64,${inlineData.data}`;
// clang-format off
return html`<devtools-link
class="image-link" title=${UIStringsNotTranslate.openImageInNewTab}
href=${imageUrl}
>
<img src=${imageUrl} alt=${UIStringsNotTranslate.imageInputSentToTheModel} />
</devtools-link>`;
// clang-format on
}
function renderActions(input: ChatMessageViewInput, output: ViewOutput): Lit.LitTemplate {
// clang-format off
return html`
<div class="ai-assistance-feedback-row">
<div class="action-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>
`: 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>
${input.onExportClick && input.isLastMessage ? html`
<devtools-button
class="export-for-agents-button"
.jslogContext=${'ai-export-for-agents'}
.variant=${Buttons.Button.Variant.OUTLINED}
.iconName=${'copy'}
aria-label=${lockedString(UIStringsNotTranslate.exportForAgents)}
@click=${input.onExportClick}
>${lockedString(UIStringsNotTranslate.exportForAgents)}</devtools-button>
${input.suggestions ? html`<div class="vertical-separator"></div>` : Lit.nothing}
` : Lit.nothing}
</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}
`;
// clang-format on
}
export class ChatMessage extends UI.Widget.Widget {
message: Message = {entity: ChatMessageEntity.USER, text: '', id: ''};
isLoading = false;
isReadOnly = false;
prompt = '';
canShowFeedbackForm = false;
isLastMessage = false;
isFirstMessage = false;
markdownRenderer!: MarkdownLitRenderer;
onSuggestionClick: (suggestion: string) => void = () => {};
onFeedbackSubmit:
(rpcId: Host.AidaClient.RpcGlobalId, rate: Host.AidaClient.Rating, feedback?: string) => void = () => {};
onCopyResponseClick: (message: ModelChatMessage) => void = () => {};
onExportClick: () => void = () => {};
walkthrough: MessageInput['walkthrough'] = {
onOpen: () => {},
onToggle: () => {},
isInlined: false,
isExpanded: false,
activeSidebarMessage: null,
inlineExpandedMessages: [],
};
#suggestionsResizeObserver = new ResizeObserver(() => this.#handleSuggestionsScrollOrResize());
#suggestionsEvaluateLayoutThrottler = new Common.Throttler.Throttler(100);
#feedbackValue = '';
#currentRating: Host.AidaClient.Rating|undefined;
#isShowingFeedbackForm = false;
#isSubmitButtonDisabled = true;
#view: View;
#viewOutput: ViewOutput = {};
#isObservingSuggestions = false;
constructor(element?: HTMLElement, view?: View) {
super(element);
this.#view = view ?? DEFAULT_VIEW;
}
override wasShown(): void {
super.wasShown();
void this.performUpdate();
this.#evaluateSuggestionsLayout();
}
override performUpdate(): Promise<void>|void {
const message =
this.message.entity === ChatMessageEntity.MODEL ? getDeduplicatedWidgetsMessage(this.message) : this.message;
this.#view(
{
message,
isLoading: this.isLoading,
isReadOnly: this.isReadOnly,
canShowFeedbackForm: this.canShowFeedbackForm,
markdownRenderer: this.markdownRenderer,
isLastMessage: this.isLastMessage,
isFirstMessage: this.isFirstMessage,
prompt: this.prompt,
onSuggestionClick: this.onSuggestionClick,
onRatingClick: this.#handleRateClick.bind(this),
onReportClick: () => UIHelpers.openInNewTab(REPORT_URL),
onCopyResponseClick: () => {
if (this.message.entity === ChatMessageEntity.MODEL) {
this.onCopyResponseClick(this.message);
}
},
onExportClick: this.onExportClick,
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);
if (this.#viewOutput.suggestionsScrollContainer && !this.#isObservingSuggestions) {
this.#suggestionsResizeObserver.observe(this.#viewOutput.suggestionsScrollContainer);
this.#isObservingSuggestions = true;
}
}
#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();
this.#isObservingSuggestions = false;
}
#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();
}
}
async function makeTimelineRangeSummaryWidget(widgetData: TimelineRangeSummaryAiWidget):
Promise<WidgetMakerResponse|null> {
const {bounds, parsedTrace, track} = widgetData.data;
let events: readonly Trace.Types.Events.Event[] = [];
// Note: right now "main" is the only track we support, but in the future we
// can imagine supporting more.
if (track === 'main') {
// To find the main thread, we first find the navigationId that is "active"
// for the given bounds. We do this because the "main" thread can change on
// navigations, so this is the most accurate way to find the main thread at
// the timespan we are interested in.
let navigationId: string|undefined;
for (const nav of parsedTrace.data.Meta.mainFrameNavigations) {
if (nav.ts <= bounds.min) {
navigationId = nav.args.data?.navigationId;
} else {
break;
}
}
const mainThread = AiAssistanceModel.AIQueries.AIQueries.findMainThread(navigationId, parsedTrace);
if (mainThread) {
events = mainThread.entries;
AiAssistanceModel.Debug.debugLog(
`TimelineRangeSummaryAiWidget found main thread. PID:`, mainThread.pid, 'TID:', mainThread.tid,
'Number of entries:', mainThread.entries.length);
}
}
if (!events) {
AiAssistanceModel.Debug.debugLog(`Warning: could not find events for TimelineRangeSummaryAiWidget`, widgetData);
return null;
}
const thirdPartyTree = new Timeline.ThirdPartyTreeView.ThirdPartyTreeViewWidget();
const mapper = Trace.EntityMapper.EntityMapper.getOrCreate(parsedTrace);
thirdPartyTree.model = {selectedEvents: events as Trace.Types.Events.Event[], parsedTrace, entityMapper: mapper};
thirdPartyTree.activeSelection = Timeline.TimelineSelection.selectionFromRangeMicroSeconds(bounds.min, bounds.max);
thirdPartyTree.refreshTree(true);
// clang-format off
const template = html`
<devtools-widget
${widget(TimelineComponents.TimelineRangeSummaryView.TimelineRangeSummaryView, {
data: {
parsedTrace,
events,
isInAIWidget: true,
startTime: Trace.Helpers.Timing.microToMilli(bounds.min),
endTime: Trace.Helpers.Timing.microToMilli(bounds.max),
thirdPartyTreeTemplate: html`${
widget(Timeline.ThirdPartyTreeView.ThirdPartyTreeViewWidget, {
maxRows: 10,
isInAIWidget: true,
model: {
selectedEvents: thirdPartyTree.selectedEvents ?? null,
parsedTrace,
entityMapper: thirdPartyTree.entityMapper(),
},
activeSelection: { bounds },
onBottomUpButtonClicked: (node: Trace.Extras.TraceTree.Node|null) => {
void Common.Revealer.reveal(new TimelineUtils.Helpers.RevealableBottomUpProfile(bounds, node ?? undefined));
},
})}`,
} as TimelineComponents.TimelineRangeSummaryView.TimelineRangeSummaryViewData,
})}
></devtools-widget>`;
// clang-format on
return {
renderedWidget: template,
revealable: new TimelineUtils.Helpers.RevealableTimeRange(bounds),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealPerformanceSummary),
title: lockedString(UIStringsNotTranslate.performanceSummary),
jslogContext: 'timeline-range-summary',
};
}
async function makeNetworkTrackWidget(widgetData: NetworkTrackAiWidget): Promise<WidgetMakerResponse|null> {
const {parsedTrace, bounds} = widgetData.data;
const dataProvider = new Timeline.TimelineFlameChartNetworkDataProvider.TimelineFlameChartNetworkDataProvider();
// clang-format off
const template = html`
<devtools-performance-agent-network-track
.data=${{
parsedTrace,
bounds,
dataProvider,
} as TimelineComponents.NetworkTrackWidget.NetworkTrackWidgetData}
></devtools-performance-agent-network-track>`;
// clang-format on
return {
renderedWidget: template,
revealable: new TimelineUtils.Helpers.RevealableTimeRange(bounds),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealNetworkActivity),
title: lockedString(UIStringsNotTranslate.networkActivitySummary),
jslogContext: 'network-track-widget',
};
}
async function makeLighthouseReportWidget(widgetData: LighthouseReportAiWidget): Promise<WidgetMakerResponse|null> {
let reportEl: HTMLElement|null = null;
try {
// Snapshot mode audits only collect individual audit results and do not generate
// top-level category score gauges.
reportEl =
Lighthouse.LighthouseReportRenderer.LighthouseReportRenderer.renderLighthouseScores(widgetData.data.report);
} catch {
reportEl = null;
}
const snapshotReport = widgetData.data.snapshotReport;
const revealLighthouseLabel = lockedString(UIStringsNotTranslate.revealLighthouse);
// When score gauges are rendered, the widget header displays the title "Lighthouse report"
// and the header button defaults to "Reveal". When score gauges are absent (snapshot mode),
// no header is rendered, so customRevealTitle labels the standalone button ("Reveal Lighthouse report").
const title = reportEl ? lockedString(UIStringsNotTranslate.lighthouseReport) : null;
const customRevealTitle = reportEl ? undefined : revealLighthouseLabel;
return {
renderedWidget: reportEl ? html`<div class="lighthouse-report-widget">${reportEl}</div>` : null,
revealable: new Lighthouse.LighthousePanel.ActiveLighthouseReport(widgetData.data.report),
accessibleRevealLabel: revealLighthouseLabel,
customRevealTitle,
title,
jslogContext: snapshotReport ? 'lighthouse-snapshot-report-widget' : 'lighthouse-report-widget',
};
}
async function makeTimelineEventSummaryWidget(widgetData: TimelineEventSummaryAiWidget):
Promise<WidgetMakerResponse|null> {
const renderedWidget = html`<devtools-widget class="timeline-event-summary-widget" ${widget(() => {
return Timeline.TimelineDetailsView.TimelineDetailsPane.makeEventWidget(
widgetData.data.event,
widgetData.data.parsedTrace,
);
})}></devtools-widget>`;
return {
renderedWidget,
revealable: new SDK.TraceObject.RevealableEvent(widgetData.data.event),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealTimelineEventSummary),
title: lockedString(UIStringsNotTranslate.timelineEventSummary),
jslogContext: 'timeline-event-summary-widget',
};
}
async function makeNetworkRequestGeneralHeadersWidget(widgetData: NetworkRequestGeneralHeadersAiWidget):
Promise<WidgetMakerResponse|null> {
const renderedWidget = html`<devtools-widget class="network-request-general-headers-widget" ${widget(() => {
return Network.RequestHeadersView.RequestHeadersView.createGeneralHeadersView(widgetData.data.request);
})}></devtools-widget>`;
return {
renderedWidget,
revealable: NetworkForward.UIRequestLocation.UIRequestLocation.tab(
widgetData.data.request,
NetworkForward.UIRequestLocation.UIRequestTabs.HEADERS_COMPONENT,
),
accessibleRevealLabel: lockedString(UIStringsNotTranslate.revealNetworkRequest),
title: lockedString(UIStringsNotTranslate.networkRequest),
jslogContext: 'network-request-general-headers-widget',
};
}