blob: a2d01eb3583bc1930fa330851d01ebf1c5280eba [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 '../inspector_main/inspector_main-meta.js';
import '../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 * 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';
const {AidaClient} = Host.AidaClient;
const {ResponseType} = AiAssistance.AiAgent;
const {NodeContext, StylingAgent} = AiAssistance.StylingAgent;
class GreenDevFloaty {
#chatContainer: HTMLDivElement;
#textField: HTMLInputElement;
#playButton: HTMLButtonElement;
#node?: SDK.DOMModel.DOMNode;
#agent?: StylingAgent;
#nodeContext?: NodeContext;
#backendNodeId?: Protocol.DOM.BackendNodeId;
// Switching this to false can help while investigating tool conflicts.
#highlightNodeOnWindowFocus = false;
constructor(document: Document) {
this.#chatContainer = document.getElementById('chat-container') as HTMLDivElement;
this.#textField = document.querySelector('.green-dev-floaty-dialog-text-field') as HTMLInputElement;
this.#playButton = document.querySelector('.green-dev-floaty-dialog-play-button') as HTMLButtonElement;
this.#playButton.addEventListener('click', () => {
if (this.#node) {
void this.runConversation();
}
});
if (this.#highlightNodeOnWindowFocus) {
window.addEventListener('focus', () => {
if (this.#node) {
this.#node.highlight();
}
});
} else {
console.error('Node highlighting on focus disabled');
}
const nodeDescriptionElement =
document.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) {
// Refresh the anchor by re-sending the show command.
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();
}
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;
}
setNode(node: SDK.DOMModel.DOMNode): void {
if (this.#node === node) {
return;
}
this.#node = node;
this.#backendNodeId = node.backendNodeId();
// Highlight the node on the page.
void node.domModel().overlayModel().clearHighlight();
if (this.#highlightNodeOnWindowFocus) {
node.highlight();
}
this.#textField.focus();
// Reset conversation for a new node
this.#agent = undefined;
this.#nodeContext = undefined;
const nodeDescriptionElement = document.querySelector('.green-dev-floaty-dialog-node-description');
if (nodeDescriptionElement) {
const id = node.getAttribute('id');
if (id) {
nodeDescriptionElement.textContent = `#${id}`;
} else {
const classes = node.classNames().join('.');
nodeDescriptionElement.textContent = node.nodeName().toLowerCase() + (classes ? `.${classes}` : '');
}
}
}
#addMessage(text: string, isUser: boolean): {content: HTMLDivElement, details?: 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);
let details: HTMLDivElement|undefined;
if (!isUser) {
details = document.createElement('div');
details.className = 'message-details';
details.style.display = 'none';
messageElement.appendChild(details);
const toggle = document.createElement('div');
toggle.className = 'message-details-toggle';
toggle.textContent = 'Show details';
toggle.onclick = () => {
if (details) {
const isHidden = details.style.display === 'none';
details.style.display = isHidden ? 'block' : 'none';
toggle.textContent = isHidden ? 'Hide details' : 'Show details';
}
};
messageElement.appendChild(toggle);
}
this.#chatContainer.appendChild(messageElement);
this.#chatContainer.scrollTop = this.#chatContainer.scrollHeight;
return {content, details};
}
async runConversation(): Promise<void> {
const query = this.#textField.value || this.#textField.placeholder;
this.#textField.value = '';
if (!this.#node) {
return;
}
if (!this.#agent) {
const aidaClient = new AidaClient();
this.#agent = new StylingAgent({aidaClient});
this.#nodeContext = new NodeContext(this.#node);
}
this.#addMessage(query, true);
const {content: aiContent, details: aiDetails} = this.#addMessage('Thinking...', false);
try {
if (!this.#nodeContext) {
throw new Error('Node context is not set.');
}
for await (const result of this.#agent.run(query, {selected: this.#nodeContext})) {
switch (result.type) {
case ResponseType.ANSWER:
aiContent.textContent = result.text;
break;
case ResponseType.ERROR:
aiContent.textContent = `Error: '${result.error}' - Protip: to use AI features you need to be signed in.`;
break;
case ResponseType.THOUGHT:
if (aiDetails) {
const thought = document.createElement('div');
thought.className = 'thought';
thought.textContent = `Thought: ${result.thought}`;
aiDetails.appendChild(thought);
}
break;
case ResponseType.ACTION:
if (aiDetails) {
const action = document.createElement('div');
action.className = 'action';
action.textContent = `Action: ${result.code}\nOutput: ${result.output}`;
aiDetails.appendChild(action);
}
break;
case ResponseType.SIDE_EFFECT:
if (aiDetails) {
const se = document.createElement('div');
se.className = 'side-effect';
se.textContent = 'Side effect detected, auto-approving for Floaty...';
aiDetails.appendChild(se);
}
// For Floaty, we might want to auto-approve or show a button.
// Let's try auto-approving for now to see if it unblocks.
result.confirm(true);
break;
default:
console.error('Unhandled response type:', result.type, result);
break;
}
this.#chatContainer.scrollTop = this.#chatContainer.scrollHeight;
}
} catch (e) {
console.error('Caught exception in runConversation:', e);
aiContent.textContent = `Exception: ${e instanceof Error ? e.message : String(e)}`;
}
}
}
let greenDevFloatyInstance: GreenDevFloaty;
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);
// Register necessary experiments to avoid "Unknown experiment" errors.
Root.Runtime.experiments.register(
Root.ExperimentNames.ExperimentName.CAPTURE_NODE_CREATION_STACKS, 'Capture node creation stacks');
Root.Runtime.experiments.register(
Root.ExperimentNames.ExperimentName.INSTRUMENTATION_BREAKPOINTS, 'Enable instrumentation breakpoints');
Root.Runtime.experiments.register(
Root.ExperimentNames.ExperimentName.USE_SOURCE_MAP_SCOPES, 'Use scope information from source maps');
Root.Runtime.experiments.register(Root.ExperimentNames.ExperimentName.LIVE_HEAP_PROFILE, 'Live heap profile');
Root.Runtime.experiments.register(Root.ExperimentNames.ExperimentName.PROTOCOL_MONITOR, 'Protocol Monitor');
Root.Runtime.experiments.register(
Root.ExperimentNames.ExperimentName.SAMPLING_HEAP_PROFILER_TIMELINE, 'Sampling heap profiler timeline');
Root.Runtime.experiments.register(Root.ExperimentNames.ExperimentName.APCA, 'APCA');
const WINDOW_LOCAL_STORAGE: Common.Settings.SettingsBackingStore = {
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(),
};
const hostUnsyncedStorage: Common.Settings.SettingsBackingStore = {
register: (name: string) =>
Host.InspectorFrontendHost.InspectorFrontendHostInstance.registerPreference(name, {synced: false}),
set: Host.InspectorFrontendHost.InspectorFrontendHostInstance.setPreference,
get: (name: string) => {
return new Promise(resolve => {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.getPreference(name, resolve);
});
},
remove: Host.InspectorFrontendHost.InspectorFrontendHostInstance.removePreference,
clear: Host.InspectorFrontendHost.InspectorFrontendHostInstance.clearPreferences,
};
const hostSyncedStorage: Common.Settings.SettingsBackingStore = {
...hostUnsyncedStorage,
register: (name: string) =>
Host.InspectorFrontendHost.InspectorFrontendHostInstance.registerPreference(name, {synced: true}),
};
const syncedStorage = new Common.Settings.SettingsStorage(prefs, hostSyncedStorage, '');
const globalStorage = new Common.Settings.SettingsStorage(prefs, hostUnsyncedStorage, '');
const localStorage = new Common.Settings.SettingsStorage(window.localStorage, WINDOW_LOCAL_STORAGE, '');
Common.Settings.Settings.instance({
forceNew: true,
syncedStorage,
globalStorage,
localStorage,
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
});
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);
// Register a revealer that brings the floaty to the front.
Common.Revealer.registerRevealer({
contextTypes() {
return [SDK.DOMModel.DeferredDOMNode, SDK.DOMModel.DOMNode];
},
async loadRevealer() {
return {
async reveal() {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.bringToFront();
},
};
},
});
await i18n.i18n.fetchAndRegisterLocaleData('en-US');
Host.InspectorFrontendHost.InspectorFrontendHostInstance.connectionReady();
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const x = parseInt(params.get('x') || '0', 10);
const y = parseInt(params.get('y') || '0', 10);
const backendNodeId = parseInt(params.get('backendNodeId') || '0', 10);
const floaty = GreenDevFloaty.instance({forceNew: null, document});
await SDK.Connections.initMainConnection(
async () => {
const targetManager = SDK.TargetManager.TargetManager.instance();
targetManager.createTarget('main', 'Main', SDK.Target.Type.FRAME, null);
// Wait for the target to be attached and initialized.
const mainTarget = await new Promise<SDK.Target.Target|null>((resolve, reject) => {
const target = targetManager.primaryPageTarget();
if (target) {
resolve(target);
return;
}
const observer = {
targetAdded: (target: SDK.Target.Target) => {
if (target === targetManager.primaryPageTarget()) {
targetManager.unobserveTargets(observer);
resolve(target);
}
},
targetRemoved: () => {},
};
targetManager.observeTargets(observer);
setTimeout(() => reject(new Error('Timeout waiting for primary page target')), 10000);
});
if (!mainTarget) {
console.error('Failed to obtain mainTarget');
return;
}
const domModel = mainTarget.model(SDK.DOMModel.DOMModel);
if (!domModel) {
console.error('DOMModel not found on mainTarget');
return;
}
let node: SDK.DOMModel.DOMNode|null = null;
if (backendNodeId) {
const nodesMap =
await domModel.pushNodesByBackendIdsToFrontend(new Set([backendNodeId as Protocol.DOM.BackendNodeId]));
node = nodesMap?.get(backendNodeId as Protocol.DOM.BackendNodeId) || null;
} else {
node = await domModel.nodeForLocation(x, y, true);
}
if (node) {
floaty.setNode(node);
} else {
console.error('No node found');
}
// Trigger overlay.
const showAnchor = (): void => {
if (backendNodeId) {
const msg = JSON.stringify({
id: 9999,
method: 'Overlay.setShowInspectedElementAnchor',
params: {inspectedElementAnchorConfig: {backendNodeId}}
});
Host.InspectorFrontendHost.InspectorFrontendHostInstance.sendMessageToBackend(msg);
}
};
showAnchor();
},
() => {
console.error('Connection lost');
});
} catch (err) {
console.error('Error during init():', err);
}
}
void init();