| // 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(); |