chrome-devtools-frontend
Version:
Chrome DevTools UI
533 lines (457 loc) • 17.3 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 * as i18n from '../../../../core/i18n/i18n.js';
import * as Root from '../../../../core/root/root.js';
import * as AIAssistance from '../../../../models/ai_assistance/ai_assistance.js';
import * as Badges from '../../../../models/badges/badges.js';
import * as GreenDev from '../../../../models/greendev/greendev.js';
import type {InsightModel} from '../../../../models/trace/insights/types.js';
import type * as Trace from '../../../../models/trace/trace.js';
import * as Buttons from '../../../../ui/components/buttons/buttons.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 type * as Overlays from '../../overlays/overlays.js';
import baseInsightComponentStyles from './baseInsightComponent.css.js';
import {md} from './Helpers.js';
import * as SidebarInsight from './SidebarInsight.js';
import type {TableState} from './Table.js';
const {html} = Lit;
const UIStrings = {
/**
* @description Text to tell the user the estimated time or size savings for this insight. "&" means "and" - space is limited to prefer abbreviated terms if possible. Text will still fit if not short, it just won't look very good, so using no abbreviations is fine if necessary.
* @example {401 ms} PH1
* @example {112 kB} PH1
*/
estimatedSavings: 'Est savings: {PH1}',
/**
* @description Text to tell the user the estimated time and size savings for this insight. "&" means "and", "Est" means "Estimated" - space is limited to prefer abbreviated terms if possible. Text will still fit if not short, it just won't look very good, so using no abbreviations is fine if necessary.
* @example {401 ms} PH1
* @example {112 kB} PH2
*/
estimatedSavingsTimingAndBytes: 'Est savings: {PH1} & {PH2}',
/**
* @description Text to tell the user the estimated time savings for this insight that is used for screen readers.
* @example {401 ms} PH1
* @example {112 kB} PH1
*/
estimatedSavingsAriaTiming: 'Estimated savings for this insight: {PH1}',
/**
* @description Text to tell the user the estimated size savings for this insight that is used for screen readers. Value is in terms of "transfer size", aka encoded/compressed data length.
* @example {401 ms} PH1
* @example {112 kB} PH1
*/
estimatedSavingsAriaBytes: 'Estimated savings for this insight: {PH1} transfer size',
/**
* @description Text to tell the user the estimated time and size savings for this insight that is used for screen readers.
* @example {401 ms} PH1
* @example {112 kB} PH2
*/
estimatedSavingsTimingAndBytesAria: 'Estimated savings for this insight: {PH1} and {PH2} transfer size',
/**
* @description Used for screen-readers as a label on the button to expand an insight to view details
* @example {LCP breakdown} PH1
*/
viewDetails: 'View details for {PH1} insight.',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/insights/BaseInsightComponent.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
interface ViewInput {
internalName: string;
model: InsightModel;
selected: boolean;
isAIAssistanceContext: boolean;
showAskAI: boolean;
estimatedSavingsString: string|null;
estimatedSavingsAriaLabel: string|null;
renderContent: () => Lit.LitTemplate;
dispatchInsightToggle: () => void;
onHeaderKeyDown: (event: KeyboardEvent) => void;
onAskAIButtonClick: () => void;
}
type View = (input: ViewInput, output: undefined, target: HTMLElement) => void;
const DEFAULT_VIEW: View = (input, output, target) => {
const {
internalName,
model,
selected,
estimatedSavingsString,
estimatedSavingsAriaLabel,
isAIAssistanceContext,
showAskAI,
dispatchInsightToggle,
renderContent,
onHeaderKeyDown,
onAskAIButtonClick,
} = input;
const containerClasses = Lit.Directives.classMap({
insight: true,
closed: !selected || isAIAssistanceContext,
'ai-assistance-context': isAIAssistanceContext,
});
let ariaLabel = `${i18nString(UIStrings.viewDetails, {PH1: model.title})}`;
if (estimatedSavingsAriaLabel) {
// space prefix is deliberate to add a gap after the view details text
ariaLabel += ` ${estimatedSavingsAriaLabel}`;
}
function renderInsightContent(): Lit.LitTemplate {
if (!selected) {
return Lit.nothing;
}
const aiLabel = 'Debug with AI';
const ariaLabel = `Ask AI about ${model.title} insight`;
const content = renderContent();
// clang-format off
return html`
<div class="insight-body">
<div class="insight-description">${md(model.description)}</div>
<div class="insight-content">${content}</div>
${showAskAI ? html`
<div class="ask-ai-btn-wrap">
<devtools-button class="ask-ai"
.variant=${Buttons.Button.Variant.OUTLINED}
.iconName=${'smart-assistant'}
data-insights-ask-ai
jslog=${VisualLogging.action(`timeline.insight-ask-ai.${internalName}`).track({click: true})}
=${onAskAIButtonClick}
aria-label=${ariaLabel}
>${aiLabel}</devtools-button>
</div>
`: Lit.nothing}
</div>`;
// clang-format on
}
function renderHoverIcon(): Lit.LitTemplate {
if (isAIAssistanceContext) {
return Lit.nothing;
}
const containerClasses = Lit.Directives.classMap({
'insight-hover-icon': true,
active: selected,
});
// clang-format off
return html`
<div class=${containerClasses} inert>
<devtools-button .data=${{
variant: Buttons.Button.Variant.ICON,
iconName: 'chevron-down',
size: Buttons.Button.Size.SMALL,
} as Buttons.Button.ButtonData}
></devtools-button>
</div>
`;
// clang-format on
}
// clang-format off
Lit.render(html`
<style>${baseInsightComponentStyles}</style>
<div class=${containerClasses}>
<header =${dispatchInsightToggle}
=${onHeaderKeyDown}
jslog=${VisualLogging.action(`timeline.toggle-insight.${internalName}`).track({click: true})}
data-insight-header-title=${model?.title}
tabIndex="0"
role="button"
aria-expanded=${selected}
aria-label=${ariaLabel}
>
${renderHoverIcon()}
<h3 class="insight-title">${model?.title}</h3>
${estimatedSavingsString ?
html`
<slot name="insight-savings" class="insight-savings">
<span title=${estimatedSavingsAriaLabel ?? ''}>${estimatedSavingsString}</span>
</slot>
</div>`
: Lit.nothing}
</header>
${renderInsightContent()}
</div>
`, target);
// clang-format on
if (selected) {
requestAnimationFrame(() => requestAnimationFrame(() => target.scrollIntoViewIfNeeded()));
}
};
export interface BaseInsightData {
/** The trace bounds for the insight set that contains this insight. */
bounds: Trace.Types.Timing.TraceWindowMicro|null;
/** The key into `insights` that contains this particular insight. */
insightSetKey: string|null;
}
export abstract class BaseInsightComponent<T extends InsightModel> extends UI.Widget.Widget {
#view: View;
abstract internalName: string;
// Tracks if this component is rendered withing the AI assistance panel.
// Currently only relevant to GreenDev.
#isAIAssistanceContext = false;
#selected = false;
#model: T|null = null;
#agentFocus: AIAssistance.AIContext.AgentFocus|null = null;
#fieldMetrics: Trace.Insights.Common.CrUXFieldMetricResults|null = null;
#parsedTrace: Trace.TraceModel.ParsedTrace|null = null;
#initialOverlays: Trace.Types.Overlays.Overlay[]|null = null;
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element, {useShadowDom: true});
this.#view = view;
}
get model(): T|null {
return this.#model;
}
protected data: BaseInsightData = {
bounds: null,
insightSetKey: null,
};
readonly sharedTableState: TableState = {
selectedRowEl: null,
selectionIsSticky: false,
};
// Insights that do support the AI feature can override this to return true.
// The "Ask AI" button will only be shown for an Insight if this
// is true and if the feature has been enabled by the user and they meet the
// requirements to use AI.
protected hasAskAiSupport(): boolean {
return false;
}
set isAIAssistanceContext(isAIAssistanceContext: boolean) {
this.#isAIAssistanceContext = isAIAssistanceContext;
this.requestUpdate();
}
set selected(selected: boolean) {
if (!this.#selected && selected) {
const options = this.getOverlayOptionsForInitialOverlays();
this.element.dispatchEvent(new SidebarInsight.InsightProvideOverlays(this.getInitialOverlays(), options));
}
if (this.#selected !== selected) {
this.#selected = selected;
this.requestUpdate();
}
}
get selected(): boolean {
return this.#selected;
}
set parsedTrace(trace: Trace.TraceModel.ParsedTrace|null) {
this.#parsedTrace = trace;
}
set model(model: T) {
this.#model = model;
this.requestUpdate();
}
set insightSetKey(insightSetKey: string|null) {
this.data.insightSetKey = insightSetKey;
this.requestUpdate();
}
get bounds(): Trace.Types.Timing.TraceWindowMicro|null {
return this.data.bounds;
}
set bounds(bounds: Trace.Types.Timing.TraceWindowMicro|null) {
this.data.bounds = bounds;
this.requestUpdate();
}
set agentFocus(agentFocus: AIAssistance.AIContext.AgentFocus|null) {
this.#agentFocus = agentFocus;
}
set fieldMetrics(fieldMetrics: Trace.Insights.Common.CrUXFieldMetricResults|null) {
this.#fieldMetrics = fieldMetrics;
}
get fieldMetrics(): Trace.Insights.Common.CrUXFieldMetricResults|null {
return this.#fieldMetrics;
}
getOverlayOptionsForInitialOverlays(): Overlays.Overlays.TimelineOverlaySetOptions {
return {updateTraceWindow: true};
}
#dispatchInsightToggle(): void {
if (!this.data.insightSetKey || !this.#model) {
// Shouldn't happen, but needed to satisfy TS.
return;
}
if (this.#parsedTrace && GreenDev.Prototypes.instance().isEnabled('inDevToolsFloaty')) {
const floatyHandled = UI.Floaty.onFloatyClick({
type: UI.Floaty.FloatyContextTypes.PERFORMANCE_INSIGHT,
data: {
insight: this.#model,
trace: this.#parsedTrace,
}
});
if (floatyHandled) {
return;
}
}
const focus = UI.Context.Context.instance().flavor(AIAssistance.AIContext.AgentFocus);
if (this.#selected) {
this.element.dispatchEvent(new SidebarInsight.InsightDeactivated());
// Clear agent (but only if currently focused on an insight).
if (focus) {
UI.Context.Context.instance().setFlavor(AIAssistance.AIContext.AgentFocus, focus.withInsight(null));
}
return;
}
if (focus) {
UI.Context.Context.instance().setFlavor(AIAssistance.AIContext.AgentFocus, focus.withInsight(this.#model));
}
Badges.UserBadges.instance().recordAction(Badges.BadgeAction.PERFORMANCE_INSIGHT_CLICKED);
this.sharedTableState.selectedRowEl?.classList.remove('selected');
this.sharedTableState.selectedRowEl = null;
this.sharedTableState.selectionIsSticky = false;
this.element.dispatchEvent(new SidebarInsight.InsightActivated(this.#model, this.data.insightSetKey));
}
/**
* Ensure that if the user presses enter or space on a header, we treat it
* like a click and toggle the insight.
*/
#onHeaderKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
this.#dispatchInsightToggle();
}
}
/**
* Replaces the initial insight overlays with the ones provided.
*
* If `overlays` is null, reverts back to the initial overlays.
*
* This allows insights to provide an initial set of overlays,
* and later temporarily replace all of those insights with a different set.
* This enables the hover/click table interactions.
*/
toggleTemporaryOverlays(
overlays: Trace.Types.Overlays.Overlay[]|null, options: Overlays.Overlays.TimelineOverlaySetOptions): void {
if (!this.#selected) {
return;
}
if (!overlays) {
this.element.dispatchEvent(new SidebarInsight.InsightProvideOverlays(
this.getInitialOverlays(), this.getOverlayOptionsForInitialOverlays()));
return;
}
this.element.dispatchEvent(new SidebarInsight.InsightProvideOverlays(overlays, options));
}
getInitialOverlays(): Trace.Types.Overlays.Overlay[] {
if (this.#initialOverlays) {
return this.#initialOverlays;
}
this.#initialOverlays = this.createOverlays();
return this.#initialOverlays;
}
protected createOverlays(): Trace.Types.Overlays.Overlay[] {
return this.#model?.createOverlays?.() ?? [];
}
protected abstract renderContent(): Lit.LitTemplate;
override performUpdate(): void {
if (!this.#model) {
return;
}
const input: ViewInput = {
internalName: this.internalName,
model: this.#model,
selected: this.#selected,
estimatedSavingsString: this.getEstimatedSavingsString(),
estimatedSavingsAriaLabel: this.#getEstimatedSavingsAriaLabel(),
isAIAssistanceContext: this.#isAIAssistanceContext,
showAskAI: this.#canShowAskAI(),
dispatchInsightToggle: () => this.#dispatchInsightToggle(),
renderContent: () => this.renderContent(),
onHeaderKeyDown: this.#onHeaderKeyDown.bind(this),
onAskAIButtonClick: () => this.#onAskAIButtonClick(),
};
this.#view(input, undefined, this.contentElement);
}
getEstimatedSavingsTime(): Trace.Types.Timing.Milli|null {
return null;
}
getEstimatedSavingsBytes(): number|null {
return this.#model?.wastedBytes ?? null;
}
#getEstimatedSavingsTextParts(): {bytesString?: string, timeString?: string} {
const savingsTime = this.getEstimatedSavingsTime();
const savingsBytes = this.getEstimatedSavingsBytes();
let timeString, bytesString;
if (savingsTime) {
timeString = i18n.TimeUtilities.millisToString(savingsTime);
}
if (savingsBytes) {
bytesString = i18n.ByteUtilities.bytesToString(savingsBytes);
}
return {
timeString,
bytesString,
};
}
#getEstimatedSavingsAriaLabel(): string|null {
const {bytesString, timeString} = this.#getEstimatedSavingsTextParts();
if (timeString && bytesString) {
return i18nString(UIStrings.estimatedSavingsTimingAndBytesAria, {
PH1: timeString,
PH2: bytesString,
});
}
if (timeString) {
return i18nString(UIStrings.estimatedSavingsAriaTiming, {
PH1: timeString,
});
}
if (bytesString) {
return i18nString(UIStrings.estimatedSavingsAriaBytes, {
PH1: bytesString,
});
}
return null;
}
getEstimatedSavingsString(): string|null {
const {bytesString, timeString} = this.#getEstimatedSavingsTextParts();
if (timeString && bytesString) {
return i18nString(UIStrings.estimatedSavingsTimingAndBytes, {
PH1: timeString,
PH2: bytesString,
});
}
if (timeString) {
return i18nString(UIStrings.estimatedSavings, {
PH1: timeString,
});
}
if (bytesString) {
return i18nString(UIStrings.estimatedSavings, {
PH1: bytesString,
});
}
return null;
}
#onAskAIButtonClick(): void {
if (!this.#agentFocus) {
return;
}
// matches the one in ai_assistance-meta.ts
const actionId = 'drjones.performance-panel-context';
if (!UI.ActionRegistry.ActionRegistry.instance().hasAction(actionId)) {
return;
}
let focus = UI.Context.Context.instance().flavor(AIAssistance.AIContext.AgentFocus);
if (focus) {
focus = focus.withInsight(this.#model);
} else {
focus = this.#agentFocus;
}
UI.Context.Context.instance().setFlavor(AIAssistance.AIContext.AgentFocus, focus);
// Trigger the AI Assistance panel to open.
const action = UI.ActionRegistry.ActionRegistry.instance().getAction(actionId);
void action.execute();
}
#canShowAskAI(): boolean {
if (this.#isAIAssistanceContext || !this.hasAskAiSupport()) {
return false;
}
// Check if the Insights AI feature enabled within Chrome for the active user.
const {devToolsAiAssistancePerformanceAgent} = Root.Runtime.hostConfig;
const askAiEnabled = Boolean(devToolsAiAssistancePerformanceAgent?.enabled);
if (!askAiEnabled) {
return false;
}
const {aidaAvailability} = Root.Runtime.hostConfig;
return aidaAvailability?.enterprisePolicyValue !== Root.Runtime.GenAiEnterprisePolicyValue.DISABLE &&
aidaAvailability?.enabled === true;
}
}