diff --git a/front_end/core/host/InspectorFrontendHost.ts b/front_end/core/host/InspectorFrontendHost.ts index 2dcfba71481..8d836194cbf 100644 --- a/front_end/core/host/InspectorFrontendHost.ts +++ b/front_end/core/host/InspectorFrontendHost.ts @@ -141,6 +141,9 @@ export class InspectorFrontendHostStub implements InspectorFrontendHostAPI { bringToFront(): void { } + sendToDevmate(prompt: string): void { + } + closeWindow(): void { } diff --git a/front_end/core/host/InspectorFrontendHostAPI.ts b/front_end/core/host/InspectorFrontendHostAPI.ts index 344dc54a478..af869fb04fa 100644 --- a/front_end/core/host/InspectorFrontendHostAPI.ts +++ b/front_end/core/host/InspectorFrontendHostAPI.ts @@ -297,6 +297,8 @@ export interface InspectorFrontendHostAPI { bringToFront(): void; + sendToDevmate(prompt: string): void; + closeWindow(): void; copyText(text: string|null|undefined): void; @@ -453,6 +455,12 @@ export interface SyncInformation { isSyncPaused?: boolean; } +export interface FileWriteResult { + success: boolean; + path: string|null; + error?: string; +} + /** * Enum for recordPerformanceHistogram * Warning: There is another definition of this enum in the DevTools code diff --git a/front_end/core/rn_experiments/experimentsImpl.ts b/front_end/core/rn_experiments/experimentsImpl.ts index 32b5996d2c5..59bd28ef743 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: 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..c787bf66d1a 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'; @@ -78,6 +79,7 @@ RNExperiments.RNExperimentsImpl.setIsReactNativeEntryPoint(true); RNExperiments.RNExperimentsImpl.Instance.enableExperimentsByDefault([ Root.Runtime.ExperimentName.JS_HEAP_PROFILER_ENABLE, Root.Runtime.ExperimentName.REACT_NATIVE_SPECIFIC_UI, + Root.Runtime.ExperimentName.ENABLE_LIVEMATE_PANEL, ]); document.addEventListener('visibilitychange', () => { diff --git a/front_end/panels/livemate/BUILD.gn b/front_end/panels/livemate/BUILD.gn new file mode 100644 index 00000000000..a5c9116e3c1 --- /dev/null +++ b/front_end/panels/livemate/BUILD.gn @@ -0,0 +1,52 @@ +# 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 = [ "LivematePanel.ts" ] + + deps = [ + "../../ui/legacy:bundle", + "../react_devtools:bundle", + ] +} + +devtools_entrypoint("bundle") { + entrypoint = "livemate.ts" + + deps = [ + ":css_files", + "../react_devtools:bundle", + ":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/LivematePanel.ts b/front_end/panels/livemate/LivematePanel.ts new file mode 100644 index 00000000000..4399d17eab4 --- /dev/null +++ b/front_end/panels/livemate/LivematePanel.ts @@ -0,0 +1,210 @@ +// 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 i18n from '../../core/i18n/i18n.js'; +import { ReactDevToolsViewBase } from '../react_devtools/ReactDevToolsViewBase.js'; + +import livematePanelStyles from './livematePanel.css.js'; + +let livematePanelInstance: LivematePanel; + +const UIStrings = { + /** + *@description Title of the React DevTools view + */ + title: '⚛️ Livemate', +} as const; +const str_ = i18n.i18n.registerUIStrings( + 'panels/livemate/LivematePanel.ts', + UIStrings +); +const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); + +export class LivematePanel extends ReactDevToolsViewBase { + static instance(): LivematePanel { + if (!livematePanelInstance) { + livematePanelInstance = new LivematePanel(); + } + return livematePanelInstance; + } + + constructor() { + super('components', i18nString(UIStrings.title)); + this.registerRequiredCSS(livematePanelStyles); + } + + protected override renderDevToolsView(): void { + this.clearView(); + + this.contentElement.classList.add('livemate-panel'); + + const model = this.model; + if (model === null) { + throw new Error('Attempted to render React DevTools panel, but the model was null'); + } + + const bridge = model.getBridgeOrThrow(); + + // 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 + const pickComponentButton = document.createElement('button'); + pickComponentButton.textContent = 'Pick component'; + pickComponentButton.setAttribute('style', 'padding: 4px 12px; cursor: pointer;'); + pickComponentButton.addEventListener('click', () => { + (bridge as unknown as {send: (event: string) => void}).send('startInspectingHost'); + }); + topRow.appendChild(pickComponentButton); + + // Breadcrumb view + const breadcrumb = document.createElement('div'); + 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;'); + + // Track the current hierarchy for prompt context + let currentHierarchy: Array<{name: string}> = []; + + // Function to update breadcrumb with component data + const updateBreadcrumb = (components: Array<{name: string}>): void => { + breadcrumb.innerHTML = ''; + + if (components.length === 0) { + return; + } + + components.forEach((component: {name: string}, index: number) => { + const componentSpan = document.createElement('span'); + componentSpan.textContent = 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'; + }); + + breadcrumb.appendChild(componentSpan); + + if (index < components.length - 1) { + const separator = document.createElement('span'); + separator.textContent = '>'; + separator.setAttribute('style', 'opacity: 0.6;'); + breadcrumb.appendChild(separator); + } + }); + }; + + // Listen for component data from React DevTools + bridge.addListener('selectElementWithViewData', (data: unknown) => { + currentHierarchy = data as Array<{name: string}>; + updateBreadcrumb(currentHierarchy); + }); + + topRow.appendChild(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 + const queryInput: HTMLTextAreaElement = document.createElement('textarea'); + queryInput.setAttribute('placeholder', 'Query to modify component...'); + 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) + queryInput.addEventListener('keydown', async (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + await sendCommandToMetro(queryInput, currentHierarchy); + } + }); + + // Send to devmate button + const sendButton = document.createElement('button'); + sendButton.textContent = 'Send to Devmate'; + sendButton.setAttribute('style', 'padding: 4px 12px; cursor: pointer; align-self: flex-end;'); + sendButton.addEventListener('click', async () => { + await sendCommandToMetro(queryInput, currentHierarchy); + }); + + bottomRow.appendChild(queryInput); + bottomRow.appendChild(sendButton); + + toolbarContainer.appendChild(topRow); + toolbarContainer.appendChild(bottomRow); + + outerWrapper.appendChild(toolbarContainer); + this.contentElement.appendChild(outerWrapper); + } +} + +async function sendCommandToMetro( + input: HTMLTextAreaElement, + currentHierarchy: Array<{name: string}> = [], + timeoutMs = 5000 +): Promise<{success: boolean; output?: string; error?: string}> { + const query = input.value; + let prompt; + if (query.trim()) { + prompt = query; + if (currentHierarchy.length > 0) { + const focusedComponent = currentHierarchy[currentHierarchy.length - 1].name; + const hierarchyStr = currentHierarchy.map(c => c.name).join(' > '); + prompt = `Focused component: ${focusedComponent}\nComponent hierarchy: ${hierarchyStr}\n\nQuery: ${query}`; + } + } + + const controller = new AbortController(); + 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', + }; + } +} diff --git a/front_end/panels/livemate/livemate-meta.ts b/front_end/panels/livemate/livemate-meta.ts new file mode 100644 index 00000000000..4ae2b75c40d --- /dev/null +++ b/front_end/panels/livemate/livemate-meta.ts @@ -0,0 +1,45 @@ +// 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..2b3fb11f492 --- /dev/null +++ b/front_end/panels/livemate/livemate.ts @@ -0,0 +1,10 @@ +// 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 LivematePanel from './LivematePanel.js'; + +export { + LivematePanel, +}; 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 diff --git a/front_end/panels/react_devtools/ReactDevToolsViewBase.ts b/front_end/panels/react_devtools/ReactDevToolsViewBase.ts index 4f6eee89c63..dc2fc2b6d62 100644 --- a/front_end/panels/react_devtools/ReactDevToolsViewBase.ts +++ b/front_end/panels/react_devtools/ReactDevToolsViewBase.ts @@ -77,6 +77,10 @@ export class ReactDevToolsViewBase extends UI.View.SimpleView implements readonly #tab: string; #model: ReactDevToolsModel | null = null; + protected get model(): ReactDevToolsModel | null { + return this.#model; + } + constructor( tab: 'components' | 'profiler', title: Platform.UIString.LocalizedString, @@ -213,4 +217,12 @@ export class ReactDevToolsViewBase extends UI.View.SimpleView implements #clearView(): void { this.contentElement.removeChildren(); } + + protected clearView(): void { + this.#clearView(); + } + + protected renderDevToolsView(): void { + this.#renderDevToolsView(); + } }