diff --git a/front_end/core/rn_experiments/experimentsImpl.ts b/front_end/core/rn_experiments/experimentsImpl.ts index 32b5996d2c5..407b18ca17a 100644 --- a/front_end/core/rn_experiments/experimentsImpl.ts +++ b/front_end/core/rn_experiments/experimentsImpl.ts @@ -191,3 +191,10 @@ Instance.register({ unstable: true, enabledByDefault: () => globalThis.enableTimelineFrames ?? false, }); + +Instance.register({ + name: RNExperimentName.ENABLE_LIVEMATE_PANEL, + title: 'Enable Livemate Panel', + unstable: true, + enabledByDefault: () => globalThis.enableLivematePanel ?? false, +}); diff --git a/front_end/core/root/Runtime.ts b/front_end/core/root/Runtime.ts index 6c59a91c84c..7dd7de7d15e 100644 --- a/front_end/core/root/Runtime.ts +++ b/front_end/core/root/Runtime.ts @@ -306,6 +306,7 @@ export enum RNExperimentName { REACT_NATIVE_SPECIFIC_UI = 'react-native-specific-ui', JS_HEAP_PROFILER_ENABLE = 'js-heap-profiler-enable', ENABLE_TIMELINE_FRAMES = 'enable-timeline-frames', + ENABLE_LIVEMATE_PANEL = 'enable-livemate-panel', } export enum ConditionName { @@ -341,6 +342,7 @@ export const enum ExperimentName { REACT_NATIVE_SPECIFIC_UI = RNExperimentName.REACT_NATIVE_SPECIFIC_UI, NOT_REACT_NATIVE_SPECIFIC_UI = '!' + RNExperimentName.REACT_NATIVE_SPECIFIC_UI, ENABLE_TIMELINE_FRAMES = RNExperimentName.ENABLE_TIMELINE_FRAMES, + ENABLE_LIVEMATE_PANEL = RNExperimentName.ENABLE_LIVEMATE_PANEL, } export enum GenAiEnterprisePolicyValue { diff --git a/front_end/entrypoints/rn_fusebox/BUILD.gn b/front_end/entrypoints/rn_fusebox/BUILD.gn index 02c7dab7442..afa400234f3 100644 --- a/front_end/entrypoints/rn_fusebox/BUILD.gn +++ b/front_end/entrypoints/rn_fusebox/BUILD.gn @@ -50,6 +50,7 @@ devtools_entrypoint("entrypoint") { "../../panels/react_devtools:components_meta", "../../panels/react_devtools:profiler_meta", "../../panels/rn_welcome:meta", + "../../panels/livemate:meta", "../../panels/security:meta", "../../panels/sensors:meta", "../../panels/timeline:meta", diff --git a/front_end/entrypoints/rn_fusebox/rn_fusebox.ts b/front_end/entrypoints/rn_fusebox/rn_fusebox.ts index 154368f7340..de463657c94 100644 --- a/front_end/entrypoints/rn_fusebox/rn_fusebox.ts +++ b/front_end/entrypoints/rn_fusebox/rn_fusebox.ts @@ -14,6 +14,7 @@ import '../../panels/network/network-meta.js'; import '../../panels/react_devtools/react_devtools_components-meta.js'; import '../../panels/react_devtools/react_devtools_profiler-meta.js'; import '../../panels/rn_welcome/rn_welcome-meta.js'; +import '../../panels/livemate/livemate-meta.js'; import '../../panels/timeline/timeline-meta.js'; import * as Host from '../../core/host/host.js'; diff --git a/front_end/global_typings/react_native.d.ts b/front_end/global_typings/react_native.d.ts index 275a1092d5c..90357fde429 100644 --- a/front_end/global_typings/react_native.d.ts +++ b/front_end/global_typings/react_native.d.ts @@ -18,6 +18,8 @@ declare global { // eslint-disable-next-line no-var var enableTimelineFrames: boolean|undefined; // eslint-disable-next-line no-var + var enableLivematePanel: boolean|undefined; + // eslint-disable-next-line no-var var reactNativeOpenInEditorButtonImage: string|undefined; // eslint-disable-next-line no-var,@typescript-eslint/naming-convention var FB_ONLY__reactNativeFeedbackLink: string|undefined; diff --git a/front_end/panels/livemate/BUILD.gn b/front_end/panels/livemate/BUILD.gn new file mode 100644 index 00000000000..1ab54d4b838 --- /dev/null +++ b/front_end/panels/livemate/BUILD.gn @@ -0,0 +1,59 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# Copyright 2024 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import("../../../scripts/build/ninja/devtools_entrypoint.gni") +import("../../../scripts/build/ninja/devtools_module.gni") +import("../../../scripts/build/ninja/generate_css.gni") +import("../visibility.gni") + +generate_css("css_files") { + sources = [ "livematePanel.css" ] +} + +devtools_module("livemate") { + sources = [ + "LivemateModel.ts", + "LivematePanel.ts", + "LivemateSpec.ts", + ] + + deps = [ + "../../core/common:bundle", + "../../core/i18n:bundle", + "../../core/platform:bundle", + "../../core/sdk:bundle", + "../../generated:protocol", + "../../ui/legacy:bundle", + ] +} + +devtools_entrypoint("bundle") { + entrypoint = "livemate.ts" + + deps = [ + ":css_files", + ":livemate", + ] + + visibility = [ + ":*", + "../../entrypoints/*", + ] + + visibility += devtools_panels_visibility +} + +devtools_entrypoint("meta") { + entrypoint = "livemate-meta.ts" + + deps = [ + ":bundle", + + "../../core/i18n:bundle", + "../../ui/legacy:bundle", + ] + + visibility = [ "../../entrypoints/*" ] +} diff --git a/front_end/panels/livemate/LivemateModel.ts b/front_end/panels/livemate/LivemateModel.ts new file mode 100644 index 00000000000..347634b9d50 --- /dev/null +++ b/front_end/panels/livemate/LivemateModel.ts @@ -0,0 +1,278 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Common from '../../core/common/common.js'; +import * as ProtocolClient from '../../core/protocol_client/protocol_client.js'; +import * as SDK from '../../core/sdk/sdk.js'; + +import { + LivemateEventType, + parseLivemateEvent, + type ElementData, +} from './LivemateSpec.js'; + +export type {ElementData} from './LivemateSpec.js'; + +let livemateModelInstance: LivemateModel | undefined; + +/** + * CDP domain constants for Livemate. + * These match the domain implemented on the C++ side. + */ +const LivemateDomain = { + ENABLE: 'Livemate.enable', + DISABLE: 'Livemate.disable', + ENABLE_INSPECTION: 'Livemate.enableInspection', + DISABLE_INSPECTION: 'Livemate.disableInspection', + INSPECTION_DATA_RECEIVED: 'Livemate.inspectionDataReceived', +} as const; + +/** + * LivemateModel handles CDP communication between React Native DevTools + * and the React Native runtime for the Livemate panel. + * + * Communication uses the Livemate CDP domain: + * - Commands: enable, disable, enableInspection, disableInspection + * - Events: inspectionDataReceived + */ +export class LivemateModel extends Common.ObjectWrapper.ObjectWrapper implements SDK.TargetManager.Observer { + #enabled = false; + #target?: SDK.Target.Target; + #inspectionEnabled = false; + #selectedElement?: ElementData; + #originalOnMessageReceived: ((message: object, target: ProtocolClient.InspectorBackend.TargetBase | null) => void) | null = null; + + private constructor() { + super(); + SDK.TargetManager.TargetManager.instance().observeTargets(this); + } + + static instance(opts: {forceNew?: boolean} = {forceNew: false}): LivemateModel { + const {forceNew} = opts; + if (!livemateModelInstance || forceNew) { + livemateModelInstance = new LivemateModel(); + } + return livemateModelInstance; + } + + get isEnabled(): boolean { + return this.#enabled; + } + + get isInspectionEnabled(): boolean { + return this.#inspectionEnabled; + } + + get selectedElement(): ElementData | undefined { + return this.#selectedElement; + } + + async targetAdded(target: SDK.Target.Target): Promise { + if (target !== SDK.TargetManager.TargetManager.instance().primaryPageTarget()) { + return; + } + this.#target = target; + } + + async targetRemoved(target: SDK.Target.Target): Promise { + if (target !== this.#target) { + return; + } + await this.disable(); + this.#target = undefined; + + const primaryPageTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); + if (primaryPageTarget) { + this.#target = primaryPageTarget; + } + } + + /** + * Enables Livemate by sending Livemate.enable CDP command. + */ + async enable(): Promise { + if (!this.#target || this.#enabled) { + return; + } + + this.#registerEventListener(); + + try { + await this.#sendCdpCommand(LivemateDomain.ENABLE); + this.#enabled = true; + this.dispatchEventToListeners(Events.STATUS_CHANGED, {enabled: true}); + } catch (e) { + console.warn('[Livemate] Failed to enable:', e); + this.#unregisterEventListener(); + } + } + + /** + * Disables Livemate by sending Livemate.disable CDP command. + */ + async disable(): Promise { + if (!this.#target || !this.#enabled) { + return; + } + + try { + await this.#sendCdpCommand(LivemateDomain.DISABLE); + } catch (e) { + console.warn('[Livemate] Failed to disable:', e); + } + + this.#unregisterEventListener(); + this.#enabled = false; + this.#inspectionEnabled = false; + this.#selectedElement = undefined; + this.dispatchEventToListeners(Events.STATUS_CHANGED, {enabled: false}); + } + + /** + * Enables inspection mode by sending Livemate.enableInspection CDP command. + */ + async enableInspection(): Promise { + if (!this.#target || !this.#enabled) { + return; + } + + try { + await this.#sendCdpCommand(LivemateDomain.ENABLE_INSPECTION); + this.#inspectionEnabled = true; + this.dispatchEventToListeners(Events.INSPECTION_STATE_CHANGED, {inspecting: true}); + } catch (e) { + console.warn('[Livemate] Failed to enable inspection:', e); + } + } + + /** + * Disables inspection mode by sending Livemate.disableInspection CDP command. + */ + async disableInspection(): Promise { + if (!this.#target || !this.#enabled) { + return; + } + + try { + await this.#sendCdpCommand(LivemateDomain.DISABLE_INSPECTION); + this.#inspectionEnabled = false; + this.dispatchEventToListeners(Events.INSPECTION_STATE_CHANGED, {inspecting: false}); + } catch (e) { + console.warn('[Livemate] Failed to disable inspection:', e); + } + } + + async toggleInspection(): Promise { + if (this.#inspectionEnabled) { + await this.disableInspection(); + } else { + await this.enableInspection(); + } + } + + /** + * Sends a raw CDP command. + */ + #sendCdpCommand(method: string, params?: object): Promise { + return new Promise((resolve, reject) => { + if (!ProtocolClient.InspectorBackend.test.sendRawMessage) { + reject(new Error('sendRawMessage not available')); + return; + } + + ProtocolClient.InspectorBackend.test.sendRawMessage( + method as ProtocolClient.InspectorBackend.QualifiedName, + params ?? null, + (result: unknown) => { + resolve(result); + } + ); + }); + } + + /** + * Registers a listener for Livemate CDP events via the message hook. + */ + #registerEventListener(): void { + this.#originalOnMessageReceived = ProtocolClient.InspectorBackend.test.onMessageReceived; + ProtocolClient.InspectorBackend.test.onMessageReceived = (message: object, target) => { + this.#handleCdpMessage(message); + if (this.#originalOnMessageReceived) { + this.#originalOnMessageReceived(message, target); + } + }; + } + + #unregisterEventListener(): void { + ProtocolClient.InspectorBackend.test.onMessageReceived = this.#originalOnMessageReceived; + this.#originalOnMessageReceived = null; + } + + /** + * Handles incoming CDP messages, filtering for Livemate.inspectionDataReceived events. + */ + #handleCdpMessage(message: object): void { + const msg = message as {method?: string, params?: {payload?: string}}; + + if (msg.method !== LivemateDomain.INSPECTION_DATA_RECEIVED) { + return; + } + + const payload = msg.params?.payload; + if (!payload) { + console.warn('[Livemate] Received inspectionDataReceived without payload'); + return; + } + + const livemateEvent = parseLivemateEvent(payload); + if (!livemateEvent) { + console.warn('[Livemate] Failed to parse event payload:', payload); + return; + } + + switch (livemateEvent.type) { + case LivemateEventType.ELEMENT_SELECTED: + if (livemateEvent.data) { + this.#selectedElement = livemateEvent.data; + this.dispatchEventToListeners(Events.ELEMENT_SELECTED, livemateEvent.data); + } + break; + + case LivemateEventType.INSPECTION_STARTED: + this.#inspectionEnabled = true; + this.dispatchEventToListeners(Events.INSPECTION_STATE_CHANGED, {inspecting: true}); + break; + + case LivemateEventType.INSPECTION_STOPPED: + this.#inspectionEnabled = false; + this.dispatchEventToListeners(Events.INSPECTION_STATE_CHANGED, {inspecting: false}); + break; + } + } +} + +export const enum Events { + STATUS_CHANGED = 'StatusChanged', + ELEMENT_SELECTED = 'ElementSelected', + INSPECTION_STATE_CHANGED = 'InspectionStateChanged', +} + +export interface StatusChangedEvent { + enabled: boolean; +} + +export interface InspectionStateChangedEvent { + inspecting: boolean; +} + +export interface EventTypes { + [Events.STATUS_CHANGED]: StatusChangedEvent; + [Events.ELEMENT_SELECTED]: ElementData; + [Events.INSPECTION_STATE_CHANGED]: InspectionStateChangedEvent; +} diff --git a/front_end/panels/livemate/LivematePanel.ts b/front_end/panels/livemate/LivematePanel.ts new file mode 100644 index 00000000000..d29af318473 --- /dev/null +++ b/front_end/panels/livemate/LivematePanel.ts @@ -0,0 +1,314 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type * as Common from '../../core/common/common.js'; +import * as i18n from '../../core/i18n/i18n.js'; +import type * as Platform from '../../core/platform/platform.js'; +import * as UI from '../../ui/legacy/legacy.js'; + +import { + LivemateModel, + Events as LivemateModelEvents, + type EventTypes as LivemateModelEventTypes, +} from './LivemateModel.js'; +import livematePanelStyles from './livematePanel.css.js'; +import type {ComponentInfo} from './LivemateSpec.js'; + +let livematePanelInstance: LivematePanel; + +const UIStrings = { + /** + * @description Title of the Livemate panel + */ + title: 'Livemate', + /** + * @description Button text for picking a component + */ + pickComponent: 'Pick component', + /** + * @description Placeholder text for the query input + */ + queryPlaceholder: 'Query to modify component...', + /** + * @description Button text for sending to devmate + */ + sendToDevmate: 'Send to Devmate', +} as const; + +const str_ = i18n.i18n.registerUIStrings( + 'panels/livemate/LivematePanel.ts', + UIStrings +); +const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); + +type ElementSelectedEvent = + Common.EventTarget.EventTargetEvent; +type InspectionStateChangedEvent = + Common.EventTarget.EventTargetEvent; + +/** + * LivematePanel provides an AI-assisted interface for inspecting and modifying + * React Native components. + * + * It uses CDP communication via LivemateModel to: + * 1. Enable/disable the Livemate subsystem on the React Native side + * 2. Toggle inspection mode for element selection + * 3. Receive selected element data via Runtime.bindingCalled + */ +export class LivematePanel extends UI.View.SimpleView { + readonly #model: LivemateModel; + #currentHierarchy: ComponentInfo[] = []; + #pickButton?: HTMLButtonElement; + #breadcrumb?: HTMLDivElement; + #queryInput?: HTMLTextAreaElement; + + static instance(): LivematePanel { + if (!livematePanelInstance) { + livematePanelInstance = new LivematePanel(); + } + return livematePanelInstance; + } + + private constructor() { + super(i18nString(UIStrings.title) as Platform.UIString.LocalizedString, true); + this.registerRequiredCSS(livematePanelStyles); + this.element.style.userSelect = 'text'; + + this.#model = LivemateModel.instance(); + this.#setupModelListeners(); + this.#renderPanel(); + } + + #setupModelListeners(): void { + this.#model.addEventListener( + LivemateModelEvents.ELEMENT_SELECTED, + this.#onElementSelected, + this + ); + this.#model.addEventListener( + LivemateModelEvents.INSPECTION_STATE_CHANGED, + this.#onInspectionStateChanged, + this + ); + } + + #onElementSelected = (event: ElementSelectedEvent): void => { + const elementData = event.data; + this.#currentHierarchy = elementData.hierarchy; + this.#updateBreadcrumb(); + }; + + #onInspectionStateChanged = (event: InspectionStateChangedEvent): void => { + const {inspecting} = event.data; + if (this.#pickButton) { + this.#pickButton.style.backgroundColor = inspecting + ? 'var(--sys-color-primary)' + : ''; + this.#pickButton.style.color = inspecting + ? 'var(--sys-color-on-primary)' + : ''; + } + }; + + override wasShown(): void { + super.wasShown(); + // Enable Livemate when the panel is shown + void this.#model.enable(); + } + + override willHide(): void { + super.willHide(); + // Disable Livemate when the panel is hidden + void this.#model.disable(); + } + + #renderPanel(): void { + this.#clearView(); + this.contentElement.classList.add('livemate-panel'); + + // Create outer wrapper for centering + const outerWrapper = document.createElement('div'); + outerWrapper.setAttribute( + 'style', + 'display: flex; justify-content: center; align-items: center; min-height: 100%;' + ); + + // Create toolbar container + const toolbarContainer = document.createElement('div'); + toolbarContainer.setAttribute( + 'style', + 'display: flex; flex-direction: column; padding: 20px; gap: 12px; max-width: 800px; width: 100%; margin: 0 20px; border: 1px solid var(--sys-color-divider); border-radius: 8px; background: var(--sys-color-surface);' + ); + + // First row: pick component button and breadcrumb + const topRow = document.createElement('div'); + topRow.setAttribute('style', 'display: flex; align-items: center; gap: 8px;'); + + // Pick component button + this.#pickButton = document.createElement('button'); + this.#pickButton.textContent = i18nString(UIStrings.pickComponent); + this.#pickButton.setAttribute('style', 'padding: 4px 12px; cursor: pointer;'); + this.#pickButton.addEventListener('click', () => { + void this.#model.toggleInspection(); + }); + topRow.appendChild(this.#pickButton); + + // Breadcrumb view + this.#breadcrumb = document.createElement('div'); + this.#breadcrumb.setAttribute( + 'style', + 'flex: 1; font-family: monospace; font-size: 12px; color: var(--sys-color-on-surface); display: flex; align-items: center; gap: 4px; flex-wrap: wrap;' + ); + topRow.appendChild(this.#breadcrumb); + + // Second row: AI query input and send button + const bottomRow = document.createElement('div'); + bottomRow.setAttribute('style', 'display: flex; align-items: center; gap: 8px;'); + + // AI query text box + this.#queryInput = document.createElement('textarea'); + this.#queryInput.setAttribute('placeholder', i18nString(UIStrings.queryPlaceholder)); + this.#queryInput.setAttribute( + 'style', + 'flex: 1; padding: 12px 16px; border: 1px solid var(--sys-color-divider); border-radius: 4px; background: var(--sys-color-cdt-base-container); color: var(--sys-color-on-surface); font-size: 14px; min-height: 100px; resize: vertical; font-family: inherit;' + ); + + // Handle Enter key to send prompt (Shift+Enter for newline) + this.#queryInput.addEventListener('keydown', async (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + await this.#sendCommand(); + } + }); + + // Send to devmate button + const sendButton = document.createElement('button'); + sendButton.textContent = i18nString(UIStrings.sendToDevmate); + sendButton.setAttribute('style', 'padding: 4px 12px; cursor: pointer; align-self: flex-end;'); + sendButton.addEventListener('click', async () => { + await this.#sendCommand(); + }); + + bottomRow.appendChild(this.#queryInput); + bottomRow.appendChild(sendButton); + + toolbarContainer.appendChild(topRow); + toolbarContainer.appendChild(bottomRow); + + outerWrapper.appendChild(toolbarContainer); + this.contentElement.appendChild(outerWrapper); + } + + #updateBreadcrumb(): void { + if (!this.#breadcrumb) { + return; + } + + this.#breadcrumb.innerHTML = ''; + + if (this.#currentHierarchy.length === 0) { + return; + } + + this.#currentHierarchy.forEach((component, index) => { + const componentSpan = document.createElement('span'); + componentSpan.textContent = component.displayName || component.name; + componentSpan.setAttribute( + 'style', + 'cursor: pointer; color: var(--sys-color-primary); text-decoration: underline;' + ); + componentSpan.addEventListener('mouseenter', () => { + componentSpan.style.opacity = '0.7'; + }); + componentSpan.addEventListener('mouseleave', () => { + componentSpan.style.opacity = '1'; + }); + + this.#breadcrumb?.appendChild(componentSpan); + + if (index < this.#currentHierarchy.length - 1) { + const separator = document.createElement('span'); + separator.textContent = '>'; + separator.setAttribute('style', 'opacity: 0.6;'); + this.#breadcrumb?.appendChild(separator); + } + }); + } + + async #sendCommand(): Promise<{success: boolean, output?: string, error?: string}> { + const input = this.#queryInput; + if (!input) { + return {success: false, error: 'Input not available'}; + } + + const query = input.value; + let prompt; + if (query.trim()) { + prompt = query; + if (this.#currentHierarchy.length > 0) { + const focusedComponent = + this.#currentHierarchy[this.#currentHierarchy.length - 1].displayName || + this.#currentHierarchy[this.#currentHierarchy.length - 1].name; + const hierarchyStr = this.#currentHierarchy + .map(c => c.displayName || c.name) + .join(' > '); + prompt = `Focused component: ${focusedComponent}\nComponent hierarchy: ${hierarchyStr}\n\nQuery: ${query}`; + } + } + + const controller = new AbortController(); + const timeoutMs = 5000; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch('http://localhost:8081/livemate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({prompt}), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + input.value = ''; + return { + success: false, + error: `HTTP ${response.status}: ${errorText}`, + }; + } + + const result = await response.json(); + return result; + } catch (e) { + clearTimeout(timeoutId); + + if (e instanceof Error && e.name === 'AbortError') { + input.value = ''; + return { + success: false, + error: 'Request timeout', + }; + } + + input.value = ''; + return { + success: false, + error: e instanceof Error ? e.message : 'Unknown error', + }; + } + } + + #clearView(): void { + this.contentElement.removeChildren(); + } +} diff --git a/front_end/panels/livemate/LivemateSpec.ts b/front_end/panels/livemate/LivemateSpec.ts new file mode 100644 index 00000000000..ef70cc86a90 --- /dev/null +++ b/front_end/panels/livemate/LivemateSpec.ts @@ -0,0 +1,75 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Binding name used for JS->DevTools communication. + * This binding is installed on the React Native side and called when + * Inspector selects an element to propagate view data to DevTools. + */ +export const LIVEMATE_BINDING_NAME = '__livemate_devtools_binding'; + +/** + * Event types that can be received from the React Native side. + */ +export const enum LivemateEventType { + ELEMENT_SELECTED = 'elementSelected', + INSPECTION_STARTED = 'inspectionStarted', + INSPECTION_STOPPED = 'inspectionStopped', +} + +/** + * Data structure for selected element information. + */ +export interface ElementData { + /** + * Component hierarchy from root to selected element. + */ + hierarchy: ComponentInfo[]; + /** + * Props of the selected component. + */ + props?: Record; + /** + * Additional metadata about the selected element. + */ + metadata?: Record; +} + +export interface ComponentInfo { + name: string; + displayName?: string; +} + +/** + * Event payload received from the binding call. + */ +export interface LivemateEvent { + type: LivemateEventType; + data?: ElementData; +} + +/** + * Parses a binding payload into a LivemateEvent. + */ +export function parseLivemateEvent(payload: string): LivemateEvent | null { + try { + const event = JSON.parse(payload) as LivemateEvent; + const validTypes: string[] = [ + LivemateEventType.ELEMENT_SELECTED, + LivemateEventType.INSPECTION_STARTED, + LivemateEventType.INSPECTION_STOPPED, + ]; + if (!event.type || !validTypes.includes(event.type)) { + return null; + } + return event; + } catch { + return null; + } +} diff --git a/front_end/panels/livemate/livemate-meta.ts b/front_end/panels/livemate/livemate-meta.ts new file mode 100644 index 00000000000..75c19ac42c3 --- /dev/null +++ b/front_end/panels/livemate/livemate-meta.ts @@ -0,0 +1,50 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as i18n from '../../core/i18n/i18n.js'; +import * as Root from '../../core/root/root.js'; +import * as UI from '../../ui/legacy/legacy.js'; + +import type * as Livemate from './livemate.js'; + +const UIStrings = { + /** + *@description Title of the Livemate panel + */ + livemate: 'Livemate', + /** + *@description Command for showing the Livemate panel + */ + showLivemate: 'Show Livemate', +} as const; + +const str_ = i18n.i18n.registerUIStrings('panels/livemate/livemate-meta.ts', UIStrings); +const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_); + +let loadedLivemateModule: (typeof Livemate | undefined); + +async function loadLivemateModule(): Promise { + if (!loadedLivemateModule) { + loadedLivemateModule = await import('./livemate.js'); + } + return loadedLivemateModule; +} + +UI.ViewManager.registerViewExtension({ + location: UI.ViewManager.ViewLocationValues.PANEL, + id: 'livemate', + title: i18nLazyString(UIStrings.livemate), + commandPrompt: i18nLazyString(UIStrings.showLivemate), + order: 100, + experiment: Root.Runtime.ExperimentName.ENABLE_LIVEMATE_PANEL, + async loadView() { + const Livemate = await loadLivemateModule(); + return Livemate.LivematePanel.LivematePanel.instance(); + }, +}); diff --git a/front_end/panels/livemate/livemate.ts b/front_end/panels/livemate/livemate.ts new file mode 100644 index 00000000000..6e497a1605a --- /dev/null +++ b/front_end/panels/livemate/livemate.ts @@ -0,0 +1,18 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as LivemateModel from './LivemateModel.js'; +import * as LivematePanel from './LivematePanel.js'; +import * as LivemateSpec from './LivemateSpec.js'; + +export { + LivemateModel, + LivematePanel, + LivemateSpec, +}; diff --git a/front_end/panels/livemate/livematePanel.css b/front_end/panels/livemate/livematePanel.css new file mode 100644 index 00000000000..93e99b3b03a --- /dev/null +++ b/front_end/panels/livemate/livematePanel.css @@ -0,0 +1,88 @@ +.livemate-panel { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + background-color: var(--sys-color-cdt-base-container); + box-sizing: border-box; +} + +.livemate-prompt-section { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 600px; + width: 100%; + margin: 0 auto; +} + +.livemate-prompt-input { + width: 100%; + min-height: 120px; + padding: 12px; + border: 1px solid var(--sys-color-divider); + border-radius: 8px; + font-family: inherit; + font-size: 14px; + line-height: 1.5; + resize: vertical; + background-color: var(--sys-color-surface); + color: var(--sys-color-on-surface); + box-sizing: border-box; +} + +.livemate-prompt-input::placeholder { + color: var(--sys-color-state-disabled-container); +} + +.livemate-prompt-input:focus { + outline: none; + border-color: var(--sys-color-primary); + box-shadow: 0 0 0 1px var(--sys-color-primary); +} + +.livemate-send-button { + align-self: flex-start; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + border-radius: 4px; + border: none; + background-color: var(--sys-color-primary); + color: var(--sys-color-on-primary); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.livemate-send-button:hover { + background-color: var(--sys-color-primary-hover); +} + +.livemate-send-button:active { + background-color: var(--sys-color-primary-pressed); +} + +.livemate-status { + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; +} + +.livemate-status:empty { + display: none; +} + +.livemate-status.error { + background-color: var(--sys-color-error-container); + color: var(--sys-color-error); +} + +.livemate-status.success { + background-color: var(--sys-color-green-container); + color: var(--sys-color-green); +} + +.livemate-status.pending { + background-color: var(--sys-color-tonal-container); + color: var(--sys-color-primary); +} diff --git a/front_end/panels/react_devtools/BUILD.gn b/front_end/panels/react_devtools/BUILD.gn index 07d928f8403..883e974fa27 100644 --- a/front_end/panels/react_devtools/BUILD.gn +++ b/front_end/panels/react_devtools/BUILD.gn @@ -38,6 +38,7 @@ devtools_entrypoint("bundle") { visibility = [ ":*", "../../entrypoints/*", + "../livemate/*", ] visibility += devtools_panels_visibility