From af8775d78bbe8db0583af9af0fff0d8bfdb031cc Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Fri, 8 May 2026 13:40:06 -0700 Subject: [PATCH 1/2] Refactor browser element selection to be event-based --- .../browserView/common/browserView.ts | 27 ++-- .../browserView/electron-main/browserView.ts | 33 ++--- .../browserViewElementInspector.ts | 138 ++++++++++------- .../electron-main/browserViewMainService.ts | 35 ++--- .../contrib/browserView/common/browserView.ts | 42 +++--- .../features/browserEditorChatFeatures.ts | 140 ++++-------------- 6 files changed, 166 insertions(+), 249 deletions(-) diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 1c04964e72113..c3171e914a6d0 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -143,6 +143,7 @@ export interface IBrowserViewState { certificateError: IBrowserViewCertificateError | undefined; storageScope: BrowserViewStorageScope; browserZoomIndex: number; + isElementSelectionActive: boolean; } export interface IBrowserViewNavigationEvent { @@ -266,6 +267,8 @@ export interface IBrowserViewService { onDynamicDidChangeFavicon(id: string): Event; onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; + onDynamicDidSelectElement(id: string): Event; + onDynamicDidChangeElementSelectionActive(id: string): Event; /** * Get all known browser views with their ownership and state information. @@ -443,26 +446,14 @@ export interface IBrowserViewService { getConsoleLogs(id: string): Promise; /** - * Start element inspection mode in a browser view. Sets up a CDP overlay that - * highlights elements on hover. When the user clicks an element, its data is - * returned and the overlay is removed. - * @param id The browser view identifier - * @param cancellationId An identifier that can be passed to {@link cancel} to abort - * @returns The inspected element data, or undefined if cancelled - */ - getElementData(id: string, cancellationId: number): Promise; - - /** - * Get element data for the currently focused element in the browser view. + * Toggle element selection mode in a browser view. + * Element selections are delivered via {@link onDynamicDidSelectElement}. + * State changes are delivered via {@link onDynamicDidChangeElementSelectionActive}. + * * @param id The browser view identifier - * @returns The focused element's data, or undefined if no element is focused - */ - getFocusedElementData(id: string): Promise; - - /** - * Cancel an in-progress request. + * @param enabled Whether to enable or disable. Omit to toggle. */ - cancel(cancellationId: number): Promise; + toggleElementSelection(id: string, enabled?: boolean): Promise; /** * Update the keybinding accelerators used in browser view context menus. diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 2b86ca7385c93..0132094814b4d 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,8 +7,7 @@ import { WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; import { BrowserViewElementInspector } from './browserViewElementInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; @@ -42,7 +41,7 @@ export class BrowserView extends Disposable { private _browserZoomIndex: number = browserZoomDefaultIndex; readonly debugger: BrowserViewDebugger; - private readonly _inspector: BrowserViewElementInspector; + readonly inspector: BrowserViewElementInspector; private _ownerWindow: ICodeWindow; private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; @@ -187,7 +186,7 @@ export class BrowserView extends Disposable { }); this.debugger = new BrowserViewDebugger(this, this.logService); - this._inspector = this._register(new BrowserViewElementInspector(this)); + this.inspector = this._register(new BrowserViewElementInspector(this)); this.setupEventListeners(); } @@ -343,6 +342,11 @@ export class BrowserView extends Disposable { // Forward key down events that weren't handled by the page to the workbench for shortcut handling. webContents.ipc.on('vscode:browserView:keydown', (_event, keyEvent: IBrowserViewKeyDownEvent) => { + // Intercept Ctrl/Cmd+Enter during element selection to pick the focused element. + if (this.inspector.isElementSelectionActive && keyEvent.key === 'Enter' && (keyEvent.ctrlKey || keyEvent.metaKey)) { + void this.inspector.pickFocusedElement(); + return; + } this._onDidKeyCommand.fire(keyEvent); }); // If the page won't be able to handle events, forward key down events directly. @@ -459,7 +463,8 @@ export class BrowserView extends Disposable { lastError: this._lastError, certificateError: this.session.trust.getCertificateError(url), storageScope: this.session.storageScope, - browserZoomIndex: this._browserZoomIndex + browserZoomIndex: this._browserZoomIndex, + isElementSelectionActive: this.inspector.isElementSelectionActive }; } @@ -522,22 +527,6 @@ export class BrowserView extends Disposable { return this._consoleLogs.join('\n'); } - /** - * Start element inspection mode. Sets up a CDP overlay that highlights elements - * on hover. When the user clicks, the element data is returned and the overlay is removed. - * @param token Cancellation token to abort the inspection. - */ - async getElementData(token: CancellationToken): Promise { - return this._inspector.getElementData(token); - } - - /** - * Get element data for the currently focused element. - */ - async getFocusedElementData(): Promise { - return this._inspector.getFocusedElementData(); - } - /** * Load a URL in this view */ @@ -609,7 +598,7 @@ export class BrowserView extends Disposable { if (options?.pageRect) { const zoomFactor = this._view.webContents.getZoomFactor(); // The visual viewport scale accounts for pinch-to-zoom magnification, which is separate from the regular zoom factor. - const visualViewportScale = await this._inspector.getVisualViewportScale(); + const visualViewportScale = await this.inspector.getVisualViewportScale(); options.screenRect = { x: options.pageRect.x * visualViewportScale * zoomFactor, y: options.pageRect.y * visualViewportScale * zoomFactor, diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts index cc32797ec1959..8701c2036918a 100644 --- a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { IElementData, IElementAncestor } from '../common/browserView.js'; import { collapseToShorthands, formatMatchedStyles, keyComputedProperties, type IMatchedStyles } from '../common/cssHelpers.js'; @@ -53,6 +53,17 @@ export class BrowserViewElementInspector extends Disposable { private readonly _connectionPromise: Promise; + private readonly _onDidSelectElement = this._register(new Emitter()); + readonly onDidSelectElement: Event = this._onDidSelectElement.event; + + private readonly _onDidChangeElementSelectionActive = this._register(new Emitter()); + readonly onDidChangeElementSelectionActive: Event = this._onDidChangeElementSelectionActive.event; + + private _elementSelectionActive = false; + get isElementSelectionActive(): boolean { return this._elementSelectionActive; } + + private _selectionStore: DisposableStore | undefined; + constructor(private readonly browser: BrowserView) { super(); @@ -80,67 +91,94 @@ export class BrowserViewElementInspector extends Disposable { } /** - * Start element inspection mode on the browser view. Sets up an - * overlay that highlights elements on hover. When the user clicks, the - * element data is returned and the overlay is removed. + * Toggle element selection mode on the browser view. + * + * When enabled, sets up a CDP overlay that highlights elements on hover. + * When the user clicks an element, its data is fired via {@link onDidSelectElement}. + * In non-continuous mode, selection is automatically disabled after the first pick. * - * @param token Cancellation token to abort the inspection. + * @param enabled Whether to enable or disable selection. Omit to toggle. */ - async getElementData(token: CancellationToken): Promise { + async toggleElementSelection(enabled?: boolean): Promise { + const newEnabled = enabled ?? !this._elementSelectionActive; + if (newEnabled === this._elementSelectionActive) { + return; + } + + if (!newEnabled) { + await this._stopElementSelection(); + return; + } + + // Start selection const connection = await this._connectionPromise; - const store = new DisposableStore(); - const result = new Promise((resolve, reject) => { - store.add(token.onCancellationRequested(() => { - resolve(undefined); - })); - - store.add(connection.onEvent(async (event) => { - if (event.method !== 'Overlay.inspectNodeRequested') { - return; - } - const params = event.params as { backendNodeId: number }; - if (!params?.backendNodeId) { - reject(new Error('Missing backendNodeId in inspectNodeRequested event')); - return; - } + // Clean up any prior selection state + this._selectionStore?.dispose(); + const store = this._selectionStore = new DisposableStore(); - try { - const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); - resolve({ - ...nodeData, - url: this.browser.getURL() - }); - } catch (err) { - reject(err); - } - })); - }); + store.add(connection.onEvent(async (event) => { + if (event.method !== 'Overlay.inspectNodeRequested') { + return; + } + + const params = event.params as { backendNodeId: number }; + if (!params?.backendNodeId) { + return; + } - try { - await connection.sendCommand('Overlay.setInspectMode', { - mode: 'searchForNode', - highlightConfig: inspectHighlightConfig, - }); - return await result; - } finally { try { - await connection.sendCommand('Overlay.setInspectMode', { - mode: 'none', - highlightConfig: { showInfo: false, showStyles: false } + const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); + this._onDidSelectElement.fire({ + ...nodeData, + url: this.browser.getURL() }); - await connection.sendCommand('Overlay.hideHighlight'); + await this._stopElementSelection(); } catch { - // Best effort cleanup + // Best effort - selection continues } - store.dispose(); + })); + + await connection.sendCommand('Overlay.setInspectMode', { + mode: 'searchForNode', + highlightConfig: inspectHighlightConfig, + }); + + this._elementSelectionActive = true; + this._onDidChangeElementSelectionActive.fire(true); + } + + private async _stopElementSelection(): Promise { + if (!this._elementSelectionActive) { + return; + } + + this._elementSelectionActive = false; + this._selectionStore?.dispose(); + this._selectionStore = undefined; + this._onDidChangeElementSelectionActive.fire(false); + + try { + const connection = await this._connectionPromise; + await connection.sendCommand('Overlay.setInspectMode', { + mode: 'none', + highlightConfig: { showInfo: false, showStyles: false } + }); + await connection.sendCommand('Overlay.hideHighlight'); + } catch { + // Best effort cleanup } } /** - * Get element data for the currently focused element. + * Fire a selection event for the currently focused element. + * Only effective when element selection is active. */ - async getFocusedElementData(): Promise { + async pickFocusedElement(): Promise { + if (!this._elementSelectionActive) { + return; + } + const connection = await this._connectionPromise; await connection.sendCommand('Runtime.enable'); @@ -150,14 +188,14 @@ export class BrowserViewElementInspector extends Disposable { }) as { result: { objectId?: string } }; if (!result?.objectId) { - return undefined; + return; } const nodeData = await extractNodeData(connection, { objectId: result.objectId }); - return { + this._onDidSelectElement.fire({ ...nodeData, url: this.browser.getURL() - }; + }); } async getVisualViewportScale(): Promise { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 4bebe104c5d83..a30efa2733acb 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,8 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -45,7 +44,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private readonly browserViews = this._register(new DisposableMap()); - private readonly _activeTokens = new Map(); private _keybindings: { [commandId: string]: string } = Object.create(null); private readonly _onDidCreateBrowserView = this._register(new Emitter()); @@ -178,6 +176,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidClose; } + onDynamicDidSelectElement(id: string) { + return this._getBrowserView(id).inspector.onDidSelectElement; + } + + onDynamicDidChangeElementSelectionActive(id: string) { + return this._getBrowserView(id).inspector.onDidChangeElementSelectionActive; + } + async getState(id: string): Promise { return this._getBrowserView(id).getState(); } @@ -281,33 +287,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).getConsoleLogs(); } - async getElementData(id: string, cancellationId: number): Promise { - return this._makeCancellable(cancellationId, (token) => this._getBrowserView(id).getElementData(token)); - } - - async getFocusedElementData(id: string): Promise { - return this._getBrowserView(id).getFocusedElementData(); - } - - async cancel(cancellationId: number): Promise { - this._activeTokens.get(cancellationId)?.cancel(); + async toggleElementSelection(id: string, enabled?: boolean): Promise { + return this._getBrowserView(id).inspector.toggleElementSelection(enabled); } async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { this._keybindings = keybindings; } - private async _makeCancellable(cancellationId: number, callback: (token: CancellationToken) => T | Promise): Promise { - const cts: CancellationTokenSource = new CancellationTokenSource(); - this._activeTokens.set(cancellationId, cts); - try { - return await callback(cts.token); - } finally { - this._activeTokens.delete(cancellationId); - cts.dispose(); - } - } - /** * Create a browser view backed by the given {@link BrowserSession}. */ diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index b6887fabb79f4..88dfae3d1a37a 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -8,7 +8,6 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -206,6 +205,7 @@ export interface IBrowserViewModel extends IDisposable { readonly zoomFactor: number; readonly canZoomIn: boolean; readonly canZoomOut: boolean; + readonly isElementSelectionActive: boolean; readonly onDidChangeSharingState: Event; readonly onDidChangeZoom: Event; @@ -220,6 +220,8 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidChangeVisibility: Event; readonly onDidClose: Event; readonly onWillDispose: Event; + readonly onDidSelectElement: Event; + readonly onDidChangeElementSelectionActive: Event; layout(bounds: IBrowserViewBounds): Promise; setVisible(visible: boolean): Promise; @@ -241,8 +243,7 @@ export interface IBrowserViewModel extends IDisposable { zoomOut(): Promise; resetZoom(): Promise; getConsoleLogs(): Promise; - getElementData(token: CancellationToken): Promise; - getFocusedElementData(): Promise; + toggleElementSelection(enabled?: boolean): Promise; } export class BrowserViewModel extends Disposable implements IBrowserViewModel { @@ -263,6 +264,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _zoomHost: string | undefined = undefined; private _sharedWithAgent: boolean = false; private _browserZoomIndex: number = browserZoomDefaultIndex; + private _isElementSelectionActive: boolean = false; private readonly _onDidChangeSharingState = this._register(new Emitter()); readonly onDidChangeSharingState: Event = this._onDidChangeSharingState.event; @@ -304,6 +306,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._certificateError = initialState.certificateError; this._storageScope = initialState.storageScope; this._browserZoomIndex = initialState.browserZoomIndex; + this._isElementSelectionActive = initialState.isElementSelectionActive; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; this._zoomHost = parseZoomHost(this._url); @@ -377,6 +380,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._visible = visible; })); + this._register(this.onDidChangeElementSelectionActive(active => { + this._isElementSelectionActive = active; + })); + this._register(this.playwrightService.onDidChangeTrackedPages(ids => { this._setSharedWithAgent(ids.includes(this.id)); })); @@ -566,12 +573,20 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.getConsoleLogs(this.id); } - async getElementData(token: CancellationToken): Promise { - return this._wrapCancellable(token, (cid) => this.browserViewService.getElementData(this.id, cid)); + get isElementSelectionActive(): boolean { + return this._isElementSelectionActive; + } + + async toggleElementSelection(enabled?: boolean): Promise { + return this.browserViewService.toggleElementSelection(this.id, enabled); + } + + get onDidSelectElement(): Event { + return this.browserViewService.onDynamicDidSelectElement(this.id); } - async getFocusedElementData(): Promise { - return this.browserViewService.getFocusedElementData(this.id); + get onDidChangeElementSelectionActive(): Event { + return this.browserViewService.onDynamicDidChangeElementSelectionActive(this.id); } private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; @@ -652,19 +667,6 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } } - private static _cancellationIdPool = 0; - private async _wrapCancellable(token: CancellationToken, callback: (cancellationId: number) => Promise): Promise { - const cancellationId = BrowserViewModel._cancellationIdPool++; - const disposable = token.onCancellationRequested(() => { - this.browserViewService.cancel(cancellationId); - }); - try { - return await callback(cancellationId); - } finally { - disposable.dispose(); - } - } - /** * Log navigation telemetry event */ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index b3a2d9e90fda3..9d806cae05e1e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -5,7 +5,6 @@ import { localize, localize2 } from '../../../../../nls.js'; import { $ } from '../../../../../base/browser/dom.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -23,6 +22,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { URI } from '../../../../../base/common/uri.js'; import { IChatWidgetService } from '../../../chat/browser/chat.js'; +import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { IElementData, IElementAncestor, BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; @@ -31,7 +31,7 @@ import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; -import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; @@ -102,7 +102,6 @@ const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('bro * console log attachment to chat, and agent sharing. */ export class BrowserEditorChatIntegration extends BrowserEditorContribution { - private _elementSelectionCts: CancellationTokenSource | undefined; private readonly _elementSelectionActiveContext: IContextKey; // Share with Agent @@ -116,6 +115,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { @ITelemetryService private readonly telemetryService: ITelemetryService, @ILogService private readonly logService: ILogService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatService private readonly chatService: IChatService, @IConfigurationService private readonly configurationService: IConfigurationService, @IDialogService private readonly dialogService: IDialogService, @IStorageService private readonly storageService: IStorageService, @@ -145,6 +145,13 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this._register(this._shareButton.onDidClick(() => { this._toggleShareWithAgent(); })); + + // Auto-disable element selection when the user sends a chat request. + this._register(this.chatService.onDidSubmitRequest(() => { + if (this.editor.model?.isElementSelectionActive) { + void this.editor.model.toggleElementSelection(false); + } + })); } override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { @@ -157,13 +164,22 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { store.add(model.onDidChangeSharingState(() => { this._updateSharingState(false); })); + store.add(model.onDidSelectElement(async data => { + try { + await this._attachElementDataToChat(data, model); + } catch (error) { + this.logService.error('BrowserEditor.addElementToChat: Failed to attach element', error); + } + })); + + // Sync context key with model state + this._elementSelectionActiveContext.set(model.isElementSelectionActive); + store.add(model.onDidChangeElementSelectionActive(active => { + this._elementSelectionActiveContext.set(active); + })); } override clear(): void { - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - } this._elementSelectionActiveContext.reset(); } @@ -244,88 +260,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { // -- Element Selection ---------------------------------------------- - /** - * Start element selection in the browser view, wait for a user selection, and add it to chat. - */ - async addElementToChat(): Promise { - // If selection is already active, cancel it - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - return; - } - - // Start new selection - const cts = new CancellationTokenSource(); - this._elementSelectionCts = cts; - this._elementSelectionActiveContext.set(true); - cts.token.onCancellationRequested(() => { - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - } - }); - - type IntegratedBrowserAddElementToChatStartEvent = {}; - - type IntegratedBrowserAddElementToChatStartClassification = { - owner: 'jruales'; - comment: 'The user initiated an Add Element to Chat action in Integrated Browser.'; - }; - - this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); - - try { - const model = this.editor.model; - if (!model) { - throw new Error('No browser view model found'); - } - - // Make the browser the focused view - this.editor.ensureBrowserFocus(); - - // Get element data from user selection - const elementData = await model.getElementData(cts.token); - if (!elementData) { - throw new Error('Element data not found'); - } - - await this._attachElementDataToChat(elementData, model); - - } catch (error) { - if (!cts.token.isCancellationRequested) { - this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); - } - } finally { - cts.dispose(true); - } - } - - /** - * Accept the currently focused element during element selection and attach it to chat. - */ - async addFocusedElementToChat(): Promise { - if (!this._elementSelectionCts) { - return; - } - - const cts = this._elementSelectionCts; - const model = this.editor.model; - if (!model) { - return; - } - - try { - const elementData = await model.getFocusedElementData(); - if (!elementData) { - return; - } - - await this._attachElementDataToChat(elementData, model); - } finally { - cts.dispose(true); - } - } - private async _attachElementDataToChat(elementData: IElementData, model: IBrowserViewModel) { const bounds = elementData.bounds; const toAttach: IChatRequestVariableEntry[] = []; @@ -479,7 +413,8 @@ class AddElementToChatAction extends Action2 { async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { if (browserEditor instanceof BrowserEditor) { - await browserEditor.getContribution(BrowserEditorChatIntegration)?.addElementToChat(); + browserEditor.ensureBrowserFocus(); + void browserEditor.model?.toggleElementSelection(undefined); } } } @@ -511,33 +446,8 @@ class AddConsoleLogsToChatAction extends Action2 { } } -class AddFocusedElementToChatAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.browser.addFocusedElementToChat', - title: localize2('browser.addFocusedElementToChat', 'Add Focused Element to Chat'), - category: BrowserActionCategory, - f1: false, - precondition: CONTEXT_BROWSER_FOCUSED, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 50, - primary: KeyMod.CtrlCmd | KeyCode.Enter, - when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - await browserEditor.getContribution(BrowserEditorChatIntegration)?.addFocusedElementToChat(); - } - } -} - registerAction2(AddElementToChatAction); registerAction2(AddConsoleLogsToChatAction); -registerAction2(AddFocusedElementToChatAction); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...workbenchConfigurationNodeBase, From a120fa2a045c080a3aadc3c0b8fca107328ee8f9 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 8 May 2026 13:56:43 -0700 Subject: [PATCH 2/2] comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browserView/electron-main/browserViewElementInspector.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts index 8701c2036918a..0c9c72a4432a9 100644 --- a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts @@ -94,8 +94,7 @@ export class BrowserViewElementInspector extends Disposable { * Toggle element selection mode on the browser view. * * When enabled, sets up a CDP overlay that highlights elements on hover. - * When the user clicks an element, its data is fired via {@link onDidSelectElement}. - * In non-continuous mode, selection is automatically disabled after the first pick. + * When the user picks an element, its data is fired via {@link onDidSelectElement}. * * @param enabled Whether to enable or disable selection. Omit to toggle. */