blob: 206cd31fbf3a98de6993574a2388a88eb31c14de [file] [log] [blame]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
import '../../core/sdk/sdk-meta.js';
import '../../models/workspace/workspace-meta.js';
import '../../panels/sensors/sensors-meta.js';
import '../../entrypoints/inspector_main/inspector_main-meta.js';
import '../../entrypoints/main/main-meta.js';
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import type * as ExperimentNames from '../../core/root/ExperimentNames.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Foundation from '../../foundation/foundation.js';
import type * as Protocol from '../../generated/protocol.js';
import * as AiAssistance from '../../models/ai_assistance/ai_assistance.js';
import type {SyncMessage} from '../../panels/greendev/GreenDevShared.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js';
const {AidaClient} = Host.AidaClient;
const {ResponseType} = AiAssistance.AiAgent;
let pendingActivationSessionId: number|null = null;
class GreenDevFloaty {
#chatContainer!: HTMLDivElement;
#textField!: HTMLInputElement;
#playButton!: HTMLButtonElement;
#node?: SDK.DOMModel.DOMNode;
#agent?: AiAssistance.StylingAgent.StylingAgent;
#nodeContext?: AiAssistance.StylingAgent.NodeContext;
#backendNodeId?: Protocol.DOM.BackendNodeId;
#syncChannel: BroadcastChannel;
#isFloatyWindow: boolean;
constructor(document: Document) {
const params = new URLSearchParams(window.location.hash.substring(1));
this.#backendNodeId = parseInt(params.get('backendNodeId') || '0', 10) as Protocol.DOM.BackendNodeId;
this.#isFloatyWindow = !!this.#backendNodeId;
this.#syncChannel = new BroadcastChannel('green-dev-sync');
this.#syncChannel.onmessage = event => {
this.#onSyncMessage(event.data);
};
this.#initFloatyMode(document);
}
#initFloatyMode(doc: Document): void {
this.#chatContainer = doc.getElementById('chat-container') as HTMLDivElement;
this.#textField = doc.querySelector('.green-dev-floaty-dialog-text-field') as HTMLInputElement;
this.#playButton = doc.querySelector('.green-dev-floaty-dialog-play-button') as HTMLButtonElement;
this.#playButton?.addEventListener('click', () => {
if (this.#node) {
void this.runConversation();
}
});
const contextText = doc.querySelector('.green-dev-floaty-dialog-context-text') as HTMLSpanElement;
if (contextText) {
contextText.style.cursor = 'pointer';
contextText.title = 'Click to show in DevTools Panel';
contextText.addEventListener('click', () => {
this.#broadcastFullState();
});
}
const learnMoreLink = doc.querySelector('.learn-more-link');
if (learnMoreLink) {
learnMoreLink.addEventListener('click', event => {
event.preventDefault();
Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(
'https://developer.chrome.com/docs/devtools/ai-assistance' as Platform.DevToolsPath.UrlString);
});
}
const nodeDescriptionElement = doc.querySelector('.green-dev-floaty-dialog-node-description') as HTMLDivElement;
nodeDescriptionElement?.addEventListener('mousemove', () => {
if (this.#node) {
this.#node.highlight();
}
});
nodeDescriptionElement?.addEventListener('mouseleave', () => {
if (this.#node && this.#backendNodeId) {
const msg = JSON.stringify({
id: 9999,
method: 'Overlay.setShowInspectedElementAnchor',
params: {inspectedElementAnchorConfig: {backendNodeId: this.#backendNodeId}}
});
Host.InspectorFrontendHost.InspectorFrontendHostInstance.sendMessageToBackend(msg);
}
});
this.#textField?.addEventListener('keydown', event => {
if (event.key === 'Enter' && this.#node) {
void this.runConversation();
}
});
this.#textField?.focus();
}
#broadcastFullState(): void {
const state = {
type: 'full-state',
messages: this.#getMessages(),
sessionId: this.#backendNodeId,
nodeDescription: document.querySelector('.green-dev-floaty-dialog-node-description')?.textContent
};
this.#syncChannel.postMessage(state);
}
#onSyncMessage(data: SyncMessage): void {
if (data.type === 'main-window-alive') {
if (pendingActivationSessionId) {
const syncChannel = new BroadcastChannel('green-dev-sync');
syncChannel.postMessage({type: 'activate-panel', sessionId: pendingActivationSessionId});
syncChannel.close();
}
} else if (data.type === 'request-session-state') {
this.#broadcastFullState();
if (pendingActivationSessionId) {
this.#syncChannel.postMessage({type: 'select-tab', sessionId: pendingActivationSessionId});
pendingActivationSessionId = null;
}
} else if (data.type === 'user-input' && data.sessionId === this.#backendNodeId) {
if (this.#textField) {
this.#textField.value = data.text ?? '';
void this.runConversation();
}
} else if (data.type === 'restore-floaty' && data.sessionId === this.#backendNodeId) {
// The main DevTools window will bring the floaty to the front,
// so the floaty window itself doesn't need to do it.
Host.InspectorFrontendHost.InspectorFrontendHostInstance.bringToFront();
}
}
static instance(opts: {
forceNew: boolean|null,
document: Document,
} = {forceNew: null, document}): GreenDevFloaty {
const {forceNew, document} = opts;
if (!greenDevFloatyInstance || forceNew) {
greenDevFloatyInstance = new GreenDevFloaty(document);
}
return greenDevFloatyInstance;
}
handlePanelRequest = (event: Common.EventTarget.EventTargetEvent<number>): void => {
pendingActivationSessionId = event.data;
this.#sendActivatePanelMessage(pendingActivationSessionId, 0);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(
'magic:open-devtools' as Platform.DevToolsPath.UrlString);
};
readonly #maxActivationRetries = 10;
readonly #activationRetryDelayMs = 200;
#sendActivatePanelMessage(sessionId: number, retryCount: number): void {
if (retryCount >= this.#maxActivationRetries) {
return;
}
const syncChannel = new BroadcastChannel('green-dev-sync');
syncChannel.postMessage({type: 'activate-panel', sessionId});
syncChannel.close();
// To ensure the activate-panel is always received, let's add a small delay and retry.
// This is a pragmatic fix for the prototype given the existing async message flow.
setTimeout(() => {
// Check if pendingActivationSessionId is still set. If it is, it means
// the panel hasn't been activated yet (or the confirmation message
// hasn't arrived), so we retry.
if (pendingActivationSessionId === sessionId) {
this.#sendActivatePanelMessage(sessionId, retryCount + 1);
}
}, this.#activationRetryDelayMs);
}
handleRestoreEvent(event: Common.EventTarget.EventTargetEvent<number>): void {
const sessionId = event.data;
// Only the main DevTools window (which is NOT a floaty window) should broadcast the restore request.
if (!this.#isFloatyWindow) {
this.#syncChannel.postMessage({type: 'restore-floaty', sessionId});
} else if (this.#backendNodeId === sessionId) {
// If a floaty window receives a restore request for its own session,
// it should bring itself to the front.
console.error('[GreenDev] Calling bringToFront for session ' + sessionId);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.bringToFront();
}
}
setNode(node: SDK.DOMModel.DOMNode): void {
if (this.#node) {
this.#node.domModel().overlayModel().removeEventListener(
SDK.OverlayModel.Events.INSPECT_PANEL_SHOW_REQUESTED, this.handlePanelRequest);
}
if (this.#node === node) {
return;
}
this.#node = node;
this.#node.domModel().overlayModel().addEventListener(
SDK.OverlayModel.Events.INSPECT_PANEL_SHOW_REQUESTED, this.handlePanelRequest);
this.#backendNodeId = node.backendNodeId();
void node.domModel().overlayModel().clearHighlight();
this.#textField?.focus();
this.#agent = undefined;
this.#nodeContext = undefined;
const nodeDescriptionElement = document.querySelector('.green-dev-floaty-dialog-node-description');
let description = '';
if (nodeDescriptionElement) {
const id = node.getAttribute('id');
if (id) {
description = `#${id}`;
} else {
const classes = node.classNames().join('.');
description = node.nodeName().toLowerCase() + (classes ? `.${classes}` : '');
}
nodeDescriptionElement.textContent = description;
}
this.#syncChannel.postMessage({type: 'node-changed', sessionId: this.#backendNodeId, nodeDescription: description});
if (this.#backendNodeId) {
const msg = JSON.stringify({
id: 9999,
method: 'Overlay.setShowInspectedElementAnchor',
params: {inspectedElementAnchorConfig: {backendNodeId: this.#backendNodeId}}
});
Host.InspectorFrontendHost.InspectorFrontendHostInstance.sendMessageToBackend(msg);
}
}
#getMessages(): Array<{text: string, isUser: boolean}> {
const messages = [];
if (this.#chatContainer) {
const messageElements = this.#chatContainer.querySelectorAll('.message');
for (const el of messageElements) {
const isUser = el.classList.contains('user-message');
const content = el.querySelector('.message-content')?.textContent || '';
messages.push({text: content, isUser});
}
}
return messages;
}
#formatError(errorMessage: string): string {
return `Error: '${errorMessage}' - Protip: to use AI features you need to be signed in.`;
}
runConversation = async(): Promise<void> => {
if (!this.#textField || !this.#node) {
return;
}
const query = this.#textField.value || this.#textField.placeholder;
this.#textField.value = '';
if (!this.#agent) {
const aidaClient = new AidaClient();
this.#agent = new AiAssistance.StylingAgent.StylingAgent({aidaClient});
this.#nodeContext = new AiAssistance.StylingAgent.NodeContext(this.#node);
}
this.#addMessageInternal(query, true);
this.#syncChannel.postMessage({
type: 'new-message',
text: query,
isUser: true,
sessionId: this.#backendNodeId,
nodeDescription: document.querySelector('.green-dev-floaty-dialog-node-description')?.textContent,
});
const aiContent = this.#addMessageInternal('Thinking...', false);
this.#syncChannel.postMessage({
type: 'new-message',
text: 'Thinking...',
isUser: false,
sessionId: this.#backendNodeId,
nodeDescription: document.querySelector('.green-dev-floaty-dialog-node-description')?.textContent,
});
try {
if (!this.#nodeContext) {
throw new Error('Node context not found.');
}
for await (const result of this.#agent.run(query, {selected: this.#nodeContext})) {
switch (result.type) {
case ResponseType.ANSWER:
aiContent.textContent = result.text;
this.#syncChannel.postMessage(
{type: 'update-last-message', text: result.text, sessionId: this.#backendNodeId});
break;
case ResponseType.ERROR:
aiContent.textContent = this.#formatError(result.error);
this.#syncChannel.postMessage(
{type: 'update-last-message', text: this.#formatError(result.error), sessionId: this.#backendNodeId});
break;
case ResponseType.SIDE_EFFECT:
result.confirm(true);
break;
default:
break;
}
if (this.#chatContainer) {
this.#chatContainer.scrollTop = this.#chatContainer.scrollHeight;
}
}
} catch (e) {
aiContent.textContent = `Exception: ${e instanceof Error ? e.message : String(e)}`;
}
};
#addMessageInternal(text: string, isUser: boolean): HTMLDivElement {
const messageElement = document.createElement('div');
messageElement.className = `message ${isUser ? 'user-message' : 'ai-message'}`;
const content = document.createElement('div');
content.className = 'message-content';
content.textContent = text;
messageElement.appendChild(content);
if (this.#chatContainer) {
this.#chatContainer.appendChild(messageElement);
this.#chatContainer.scrollTop = this.#chatContainer.scrollHeight;
}
return content;
}
}
let greenDevFloatyInstance: GreenDevFloaty;
function safeRegisterExperiment(name: string, title: string): void {
try {
Root.Runtime.experiments.register(name as ExperimentNames.ExperimentName, title);
} catch (e) {
console.error('Unable to register experiment ', name, title, e);
}
}
async function init(): Promise<void> {
try {
Root.Runtime.Runtime.setPlatform(Host.Platform.platform());
const [config, prefs] = await Promise.all([
new Promise<Root.Runtime.HostConfig>(
resolve => Host.InspectorFrontendHost.InspectorFrontendHostInstance.getHostConfig(resolve)),
new Promise<Record<string, string>>(
resolve => Host.InspectorFrontendHost.InspectorFrontendHostInstance.getPreferences(resolve)),
]);
Object.assign(Root.Runtime.hostConfig, config);
safeRegisterExperiment(
Root.ExperimentNames.ExperimentName.CAPTURE_NODE_CREATION_STACKS, 'Capture node creation stacks');
safeRegisterExperiment(
Root.ExperimentNames.ExperimentName.INSTRUMENTATION_BREAKPOINTS, 'Enable instrumentation breakpoints');
safeRegisterExperiment(
Root.ExperimentNames.ExperimentName.USE_SOURCE_MAP_SCOPES, 'Use scope information from source maps');
safeRegisterExperiment(Root.ExperimentNames.ExperimentName.LIVE_HEAP_PROFILE, 'Live heap profile');
safeRegisterExperiment(Root.ExperimentNames.ExperimentName.PROTOCOL_MONITOR, 'Protocol Monitor');
safeRegisterExperiment(
Root.ExperimentNames.ExperimentName.SAMPLING_HEAP_PROFILER_TIMELINE, 'Sampling heap profiler timeline');
safeRegisterExperiment(Root.ExperimentNames.ExperimentName.APCA, 'APCA');
const hostUnsyncedStorage: Common.Settings.SettingsBackingStore = {
register: (name: string) =>
Host.InspectorFrontendHost.InspectorFrontendHostInstance.registerPreference(name, {synced: false}),
set: Host.InspectorFrontendHost.InspectorFrontendHostInstance.setPreference,
get: (name: string) =>
new Promise(resolve => Host.InspectorFrontendHost.InspectorFrontendHostInstance.getPreference(name, resolve)),
remove: Host.InspectorFrontendHost.InspectorFrontendHostInstance.removePreference,
clear: Host.InspectorFrontendHost.InspectorFrontendHostInstance.clearPreferences,
};
const syncedStorage = new Common.Settings.SettingsStorage(prefs, hostUnsyncedStorage, '');
const globalStorage = new Common.Settings.SettingsStorage(prefs, hostUnsyncedStorage, '');
const localStorage = new Common.Settings.SettingsStorage(
window.localStorage, {
register(_setting: string): void{},
async get(setting: string): Promise<string> {
return window.localStorage.getItem(setting) as unknown as string;
},
set(setting: string, value: string): void {
window.localStorage.setItem(setting, value);
},
remove(setting: string): void {
window.localStorage.removeItem(setting);
},
clear: () => window.localStorage.clear(),
},
'');
Common.Settings.Settings.instance({
forceNew: true,
syncedStorage,
globalStorage,
localStorage,
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
});
UI.UIUtils.initializeUIUtils(document);
ThemeSupport.ThemeSupport.instance({
forceNew: true,
setting: Common.Settings.Settings.instance().moduleSetting('ui-theme'),
});
UI.ZoomManager.ZoomManager.instance(
{forceNew: true, win: window, frontendHost: Host.InspectorFrontendHost.InspectorFrontendHostInstance});
const settingLanguage = Common.Settings.Settings.instance().moduleSetting<string>('language').get();
i18n.DevToolsLocale.DevToolsLocale.instance({
create: true,
data: {
navigatorLanguage: navigator.language,
settingLanguage,
lookupClosestDevToolsLocale: i18n.i18n.lookupClosestSupportedDevToolsLocale,
},
});
const universe = new Foundation.Universe.Universe({
settingsCreationOptions: {
syncedStorage,
globalStorage,
localStorage,
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
}
});
Root.DevToolsContext.setGlobalInstance(universe.context);
await i18n.i18n.fetchAndRegisterLocaleData('en-US');
Host.InspectorFrontendHost.InspectorFrontendHostInstance.connectionReady();
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const backendNodeId = parseInt(params.get('backendNodeId') || '0', 10);
const floaty = GreenDevFloaty.instance({forceNew: null, document});
if (backendNodeId) {
await SDK.Connections.initMainConnection(async () => {
const targetManager = SDK.TargetManager.TargetManager.instance();
targetManager.createTarget('main', 'Main', SDK.Target.Type.FRAME, null);
const mainTarget = await new Promise<SDK.Target.Target|null>(resolve => {
const t = targetManager.primaryPageTarget();
if (t) {
resolve(t);
return;
}
const observer = {
targetAdded: (target: SDK.Target.Target) => {
if (target === targetManager.primaryPageTarget()) {
targetManager.unobserveTargets(observer);
resolve(target);
}
},
targetRemoved: () => {},
};
targetManager.observeTargets(observer);
});
if (!mainTarget) {
return;
}
const domModel = mainTarget.model(SDK.DOMModel.DOMModel);
if (!domModel) {
return;
}
// Add listener for floaty restore events
const overlayModel = mainTarget.model(SDK.OverlayModel.OverlayModel);
if (overlayModel) {
overlayModel.addEventListener(
SDK.OverlayModel.Events.INSPECTED_ELEMENT_WINDOW_RESTORED, floaty.handleRestoreEvent, floaty);
}
const nodesMap =
await domModel.pushNodesByBackendIdsToFrontend(new Set([backendNodeId as Protocol.DOM.BackendNodeId]));
const node = nodesMap?.get(backendNodeId as Protocol.DOM.BackendNodeId) || null;
if (node) {
floaty.setNode(node);
}
}, () => {});
} else {
const targetManager = SDK.TargetManager.TargetManager.instance();
const observer = {
targetAdded: (target: SDK.Target.Target) => {
if (target.type() === SDK.Target.Type.FRAME) {
const overlayModel = target.model(SDK.OverlayModel.OverlayModel);
if (overlayModel) {
overlayModel.addEventListener(
SDK.OverlayModel.Events.INSPECTED_ELEMENT_WINDOW_RESTORED, floaty.handleRestoreEvent, floaty);
overlayModel.addEventListener(
SDK.OverlayModel.Events.INSPECT_PANEL_SHOW_REQUESTED, floaty.handlePanelRequest);
}
}
},
targetRemoved: (target: SDK.Target.Target) => {
if (target.type() === SDK.Target.Type.FRAME) {
const overlayModel = target.model(SDK.OverlayModel.OverlayModel);
if (overlayModel) {
overlayModel.removeEventListener(
SDK.OverlayModel.Events.INSPECTED_ELEMENT_WINDOW_RESTORED, floaty.handleRestoreEvent, floaty);
overlayModel.removeEventListener(
SDK.OverlayModel.Events.INSPECT_PANEL_SHOW_REQUESTED, floaty.handlePanelRequest);
}
}
},
};
targetManager.observeTargets(observer);
}
} catch (err) {
console.error('[GreenDev] FATAL ERROR during init():', err);
}
}
void init();