From 2deb8a7d5dc41cc2ca89ddc8461bc807df84a254 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:57:37 -0800 Subject: [PATCH] cool hover for integrated browser added elements (#295963) * cool hover for integrated browser added elements * some fixes and addressing comments * cleanup --- .../browserElements/common/browserElements.ts | 11 + .../nativeBrowserElementsMainService.ts | 150 ++++++++-- .../electron-browser/browserEditor.ts | 136 ++++++++- .../attachments/chatAttachmentWidgets.ts | 282 +++++++++++++++++- .../attachments/simpleBrowserEditorOverlay.ts | 5 + .../chat/browser/widget/media/chat.css | 125 ++++++++ .../common/attachments/chatVariableEntries.ts | 11 + 7 files changed, 679 insertions(+), 41 deletions(-) diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 5f6b0c82414..2973d9db939 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -9,10 +9,21 @@ import { IRectangle } from '../../window/common/window.js'; export const INativeBrowserElementsService = createDecorator('nativeBrowserElementsService'); +export interface IElementAncestor { + readonly tagName: string; + readonly id?: string; + readonly classNames?: string[]; +} + export interface IElementData { readonly outerHTML: string; readonly computedStyle: string; readonly bounds: IRectangle; + readonly ancestors?: IElementAncestor[]; + readonly attributes?: Record; + readonly computedStyles?: Record; + readonly dimensions?: { readonly top: number; readonly left: number; readonly width: number; readonly height: number }; + readonly innerText?: string; } /** diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index 4e53a021745..5e018a6d718 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IElementData, INativeBrowserElementsService, IBrowserTargetLocator } from '../common/browserElements.js'; +import { IElementData, IElementAncestor, INativeBrowserElementsService, IBrowserTargetLocator } from '../common/browserElements.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IRectangle } from '../../window/common/window.js'; import { BrowserWindow, webContents } from 'electron'; @@ -23,17 +23,20 @@ interface NodeDataResponse { outerHTML: string; computedStyle: string; bounds: IRectangle; + ancestors?: IElementAncestor[]; + attributes?: Record; + computedStyles?: Record; + dimensions?: { top: number; left: number; width: number; height: number }; + innerText?: string; } const MAX_CONSOLE_LOG_ENTRIES = 1000; + +/** Stores captured console log entries, keyed by a locator string. */ const consoleLogStore = new Map(); -function locatorKey(locator: IBrowserTargetLocator): string { - const key = locator.browserViewId ?? locator.webviewId; - if (!key) { - return 'unknown'; - } - return key; +function locatorKey(locator: IBrowserTargetLocator): string | undefined { + return locator.browserViewId ?? locator.webviewId; } export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService { @@ -51,6 +54,10 @@ export class NativeBrowserElementsMainService extends Disposable implements INat async getConsoleLogs(windowId: number | undefined, locator: IBrowserTargetLocator): Promise { const key = locatorKey(locator); + if (!key) { + return undefined; + } + const entries = consoleLogStore.get(key); if (!entries || entries.length === 0) { return undefined; @@ -63,7 +70,10 @@ export class NativeBrowserElementsMainService extends Disposable implements INat if (!window?.win) { return undefined; } + const windowWebContents = window.win.webContents; + // For BrowserView targets, listen to the console-message event directly + // on the BrowserView's webContents. No CDP needed. let targetWebContents: Electron.WebContents | undefined; if (locator.browserViewId) { targetWebContents = this.browserViewMainService.tryGetBrowserView(locator.browserViewId)?.webContents; @@ -74,6 +84,11 @@ export class NativeBrowserElementsMainService extends Disposable implements INat } const key = locatorKey(locator); + if (!key) { + return undefined; + } + + // Initialize log store for this locator if it doesn't exist yet (don't clear on restart) if (!consoleLogStore.has(key)) { consoleLogStore.set(key, []); } @@ -92,32 +107,27 @@ export class NativeBrowserElementsMainService extends Disposable implements INat const cleanupListeners = () => { targetWebContents?.off('console-message', onConsoleMessage); - window.win?.webContents.off('ipc-message', onIpcMessage); + targetWebContents?.off('destroyed', onTargetDestroyed); + windowWebContents.off('ipc-message', onIpcMessage); }; - const onIpcMessage = async (_event: Electron.Event, channel: string, closedCancelAndDetachId: number) => { + const onIpcMessage = (_event: Electron.Event, channel: string, closedCancelAndDetachId: number) => { if (channel === `vscode:cancelConsoleSession${cancelAndDetachId}`) { if (cancelAndDetachId !== closedCancelAndDetachId) { return; } cleanupListeners(); - consoleLogStore.delete(key); } }; + const onTargetDestroyed = () => { + cleanupListeners(); + }; + targetWebContents.on('console-message', onConsoleMessage); - - targetWebContents.once('destroyed', () => { - cleanupListeners(); - consoleLogStore.delete(key); - }); - - token.onCancellationRequested(() => { - cleanupListeners(); - consoleLogStore.delete(key); - }); - - window.win.webContents.on('ipc-message', onIpcMessage); + targetWebContents.on('destroyed', onTargetDestroyed); + windowWebContents.on('ipc-message', onIpcMessage); + token.onCancellationRequested(cleanupListeners); } /** @@ -376,7 +386,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat }, sessionId); } catch (e) { debuggers.detach(); - throw new Error('No target found', e); + throw new Error('No target found', { cause: e }); } if (!targetSessionId) { @@ -409,7 +419,16 @@ export class NativeBrowserElementsMainService extends Disposable implements INat height: clippedBounds.height * zoomFactor }; - return { outerHTML: nodeData.outerHTML, computedStyle: nodeData.computedStyle, bounds: scaledBounds }; + return { + outerHTML: nodeData.outerHTML, + computedStyle: nodeData.computedStyle, + bounds: scaledBounds, + ancestors: nodeData.ancestors, + attributes: nodeData.attributes, + computedStyles: nodeData.computedStyles, + dimensions: nodeData.dimensions, + innerText: nodeData.innerText, + }; } async getNodeData(sessionId: string, debuggers: Electron.Debugger, window: BrowserWindow, cancellationId?: number): Promise { @@ -460,10 +479,91 @@ export class NativeBrowserElementsMainService extends Disposable implements INat throw new Error('Failed to get outerHTML.'); } + // Extract additional structured data for rich hover + let ancestors: IElementAncestor[] | undefined; + let attributes: Record | undefined; + let computedStyles: Record | undefined; + let innerText: string | undefined; + + try { + // Build ancestor chain using JavaScript evaluation (more reliable than DOM.describeNode for parent walking) + const { object: resolvedNode } = await debuggers.sendCommand('DOM.resolveNode', { nodeId }, sessionId); + if (resolvedNode?.objectId) { + const { result: ancestorResult } = await debuggers.sendCommand('Runtime.callFunctionOn', { + objectId: resolvedNode.objectId, + functionDeclaration: `function() { + var chain = []; + var el = this; + while (el && el.nodeType === 1) { + var entry = { tagName: el.tagName.toLowerCase() }; + if (el.id) { entry.id = el.id; } + if (el.className && typeof el.className === 'string') { + var cls = el.className.trim().split(/\\s+/).filter(Boolean); + if (cls.length > 0) { entry.classNames = cls; } + } + chain.unshift(entry); + el = el.parentElement; + } + return chain; + }`, + returnByValue: true, + }, sessionId); + if (ancestorResult?.value && Array.isArray(ancestorResult.value)) { + ancestors = ancestorResult.value; + } + + // Get attributes from the element + const { result: attrResult } = await debuggers.sendCommand('Runtime.callFunctionOn', { + objectId: resolvedNode.objectId, + functionDeclaration: `function() { + var attrs = {}; + for (var i = 0; i < this.attributes.length; i++) { + attrs[this.attributes[i].name] = this.attributes[i].value; + } + return attrs; + }`, + returnByValue: true, + }, sessionId); + if (attrResult?.value) { + attributes = attrResult.value; + } + + // Get inner text (truncated) + const { result: innerTextResult } = await debuggers.sendCommand('Runtime.callFunctionOn', { + objectId: resolvedNode.objectId, + functionDeclaration: 'function() { return this.innerText; }', + returnByValue: true, + }, sessionId); + if (innerTextResult?.value) { + const text = String(innerTextResult.value).trim(); + innerText = text.length > 100 ? text.substring(0, 100) + '\u2026' : text; + } + } + + // Capture all computed styles for model-facing element context. + const { computedStyle: computedStyleArray } = await debuggers.sendCommand('CSS.getComputedStyleForNode', { nodeId }, sessionId); + if (computedStyleArray) { + computedStyles = {}; + for (const prop of computedStyleArray) { + if (prop.name && typeof prop.value === 'string') { + computedStyles[prop.name] = prop.value; + } + } + } + } catch { + // Non-critical: if any enrichment fails, we still have the core data + } + + // TODO: computedStyle here is actually the matched styles resolve({ outerHTML, computedStyle: formatted, - bounds: { x, y, width, height } + bounds: { x, y, width, height }, + ancestors, + attributes, + computedStyles, + dimensions: { top: y, left: x, width, height }, + innerText, }); } catch (err) { debuggers.off('message', onMessage); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index e9f065deb3e..42f9c41bf53 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -43,7 +43,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; -import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementAncestor, IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; import { logBrowserOpen } from './browserViewTelemetry.js'; import { URI } from '../../../../base/common/uri.js'; @@ -372,6 +372,7 @@ export class BrowserEditor extends EditorPane { // Update navigation bar and context keys from model this.updateNavigationState(navEvent); + // Ensure a console session is active while a page URL is loaded. if (navEvent.url) { this.startConsoleSession(); } else { @@ -434,6 +435,11 @@ export class BrowserEditor extends EditorPane { this.layoutBrowserContainer(); this.updateVisibility(); this.doScreenshot(); + + // Start console log capture session if a URL is loaded + if (this._model.url) { + this.startConsoleSession(); + } } protected override setEditorVisible(visible: boolean): void { @@ -705,18 +711,21 @@ export class BrowserEditor extends EditorPane { // Prepare HTML/CSS context const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); - let value = (attachCss ? 'Attached HTML and CSS Context' : 'Attached HTML Context') + '\n\n' + elementData.outerHTML; - if (attachCss) { - value += '\n\n' + elementData.computedStyle; - } + const value = this.createElementContextValue(elementData, displayName, attachCss); toAttach.push({ id: 'element-' + Date.now(), name: displayName, fullName: displayName, value: value, + modelDescription: 'Structured browser element context with HTML path, attributes, and computed styles.', kind: 'element', icon: ThemeIcon.fromId(Codicon.layout.id), + ancestors: elementData.ancestors, + attributes: elementData.attributes, + computedStyles: elementData.computedStyles, + dimensions: elementData.dimensions, + innerText: elementData.innerText, }); // Attach screenshot if enabled @@ -770,6 +779,9 @@ export class BrowserEditor extends EditorPane { } } + /** + * Grab the current console logs from the active console session and attach them to chat. + */ async addConsoleLogsToChat(): Promise { const resourceUri = this.input?.resource; if (!resourceUri) { @@ -790,8 +802,9 @@ export class BrowserEditor extends EditorPane { name: localize('consoleLogs', 'Console Logs'), fullName: localize('consoleLogs', 'Console Logs'), value: logs, + modelDescription: 'Console logs captured from Integrated Browser.', kind: 'element', - icon: ThemeIcon.fromId(Codicon.output.id), + icon: ThemeIcon.fromId(Codicon.terminal.id), }); const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; @@ -801,8 +814,11 @@ export class BrowserEditor extends EditorPane { } } + /** + * Start a console session to capture logs from the browser view. + */ private startConsoleSession(): void { - // don't restart if already running + // Don't restart if already running if (this._consoleSessionCts) { return; } @@ -823,6 +839,9 @@ export class BrowserEditor extends EditorPane { }); } + /** + * Stop the active console session. + */ private stopConsoleSession(): void { if (this._consoleSessionCts) { this._consoleSessionCts.dispose(true); @@ -830,6 +849,109 @@ export class BrowserEditor extends EditorPane { } } + private createElementContextValue(elementData: IElementData, displayName: string, attachCss: boolean): string { + const sections: string[] = []; + sections.push('Attached Element Context from Integrated Browser'); + sections.push(`Element: ${displayName}`); + + const htmlPath = this.formatElementPath(elementData.ancestors); + if (htmlPath) { + sections.push(`HTML Path:\n${htmlPath}`); + } + + const attributeTable = this.formatElementMap(elementData.attributes); + if (attributeTable) { + sections.push(`Attributes:\n${attributeTable}`); + } + + const computedStyleTable = this.formatElementMap(elementData.computedStyles); + if (computedStyleTable) { + sections.push(`Computed Styles:\n${computedStyleTable}`); + } + + if (elementData.dimensions) { + const { top, left, width, height } = elementData.dimensions; + sections.push( + `Dimensions:\n- top: ${Math.round(top)}px\n- left: ${Math.round(left)}px\n- width: ${Math.round(width)}px\n- height: ${Math.round(height)}px` + ); + } + + const innerText = elementData.innerText?.trim(); + if (innerText) { + sections.push(`Inner Text:\n\`\`\`text\n${innerText}\n\`\`\``); + } + + sections.push(`Outer HTML:\n\`\`\`html\n${elementData.outerHTML}\n\`\`\``); + + if (attachCss) { + sections.push(`Full Computed CSS:\n\`\`\`css\n${elementData.computedStyle}\n\`\`\``); + } + + return sections.join('\n\n'); + } + + private formatElementPath(ancestors: readonly IElementAncestor[] | undefined): string | undefined { + if (!ancestors || ancestors.length === 0) { + return undefined; + } + + return ancestors + .map(ancestor => { + const classes = ancestor.classNames?.length ? `.${ancestor.classNames.join('.')}` : ''; + const id = ancestor.id ? `#${ancestor.id}` : ''; + return `${ancestor.tagName}${id}${classes}`; + }) + .join(' > '); + } + + private formatElementMap(entries: Readonly> | undefined): string | undefined { + if (!entries || Object.keys(entries).length === 0) { + return undefined; + } + + const normalizedEntries = new Map(Object.entries(entries)); + const lines: string[] = []; + + const marginShorthand = this.createBoxShorthand(normalizedEntries, 'margin'); + if (marginShorthand) { + lines.push(`- margin: ${marginShorthand}`); + } + + const paddingShorthand = this.createBoxShorthand(normalizedEntries, 'padding'); + if (paddingShorthand) { + lines.push(`- padding: ${paddingShorthand}`); + } + + for (const [name, value] of Array.from(normalizedEntries.entries()).sort(([a], [b]) => a.localeCompare(b))) { + lines.push(`- ${name}: ${value}`); + } + + return lines.join('\n'); + } + + private createBoxShorthand(entries: Map, propertyName: 'margin' | 'padding'): string | undefined { + const topKey = `${propertyName}-top`; + const rightKey = `${propertyName}-right`; + const bottomKey = `${propertyName}-bottom`; + const leftKey = `${propertyName}-left`; + + const top = entries.get(topKey); + const right = entries.get(rightKey); + const bottom = entries.get(bottomKey); + const left = entries.get(leftKey); + + if (top === undefined || right === undefined || bottom === undefined || left === undefined) { + return undefined; + } + + entries.delete(topKey); + entries.delete(rightKey); + entries.delete(bottomKey); + entries.delete(leftKey); + + return `${top} ${right} ${bottom} ${left}`; + } + /** * Update navigation state and context keys */ diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 7744f6a9e9f..211d7aa9381 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -11,6 +11,7 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { HoverStyle, IDelayedHoverOptions, type IHoverLifecycleOptions, type IHoverOptions } from '../../../../../base/browser/ui/hover/hover.js'; import { createInstantHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import * as event from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -19,6 +20,7 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname } from '../../../../../base/common/path.js'; +import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IRange } from '../../../../../editor/common/core/range.js'; @@ -77,6 +79,17 @@ const commonHoverLifecycleOptions: IHoverLifecycleOptions = { groupId: 'chat-attachments', }; +const KEY_ELEMENT_HOVER_COMPUTED_STYLE_PROPERTIES = [ + 'display', + 'position', + 'margin', + 'padding', + 'font-size', + 'font-family', + 'color', + 'background-color' +]; + abstract class AbstractChatAttachmentWidget extends Disposable { public readonly element: HTMLElement; public readonly label: IResourceLabel; @@ -927,7 +940,8 @@ export class ElementChatAttachmentWidget extends AbstractChatAttachmentWidget { @ICommandService commandService: ICommandService, @IOpenerService openerService: IOpenerService, @IConfigurationService configurationService: IConfigurationService, - @IEditorService editorService: IEditorService, + @IEditorService private readonly editorService: IEditorService, + @IHoverService private readonly hoverService: IHoverService, ) { super(attachment, options, container, contextResourceLabels, currentLanguageModel, commandService, openerService, configurationService); @@ -940,17 +954,267 @@ export class ElementChatAttachmentWidget extends AbstractChatAttachmentWidget { const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel; this.label.setLabel(withIcon, undefined, { title: localize('chat.clickToViewContents', "Click to view the contents of: {0}", attachmentLabel) }); + this._register(this.hoverService.setupDelayedHover(this.element, this.getHoverContent(attachment), commonHoverLifecycleOptions)); + this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, async () => { - const content = attachment.value?.toString() || ''; - await editorService.openEditor({ - resource: undefined, - contents: content, - options: { - pinned: true - } - }); + await this.openElementAttachment(attachment); })); } + + private getHoverContent(attachment: IElementVariableEntry): IDelayedHoverOptions { + if (!this.shouldRenderRichElementHover(attachment)) { + return this.getSimpleHoverContent(attachment); + } + + const hoverElement = dom.$('div.chat-attached-context-hover.chat-element-hover'); + + // Wrap all sections in a scrollable container for VS Code styled scrollbar + const scrollableContent = dom.$('div.chat-element-hover-content'); + + // ELEMENT section: show the selected element tag with all attributes + { + const section = dom.$('div.chat-element-hover-section'); + const header = dom.$('div.chat-element-hover-header', {}, localize('chat.elementHover.element', "ELEMENT")); + section.appendChild(header); + const elementPre = dom.$('pre.chat-element-hover-code'); + const elementCode = dom.$('code'); + // Build the element tag from the outerHTML (just the opening tag) + const tagDisplay = this.formatElementTag(attachment); + elementCode.textContent = tagDisplay; + elementPre.appendChild(elementCode); + section.appendChild(elementPre); + scrollableContent.appendChild(section); + } + + // ATTRIBUTES section + if (attachment.attributes && Object.keys(attachment.attributes).length > 0) { + const section = dom.$('div.chat-element-hover-section'); + const header = dom.$('div.chat-element-hover-header', {}, localize('chat.elementHover.attributes', "ATTRIBUTES")); + section.appendChild(header); + const table = dom.$('div.chat-element-hover-table'); + for (const [name, value] of Object.entries(attachment.attributes)) { + const row = dom.$('div.chat-element-hover-row'); + row.appendChild(dom.$('span.chat-element-hover-label', {}, `${name}:`)); + row.appendChild(dom.$('span.chat-element-hover-value', {}, value)); + table.appendChild(row); + } + section.appendChild(table); + scrollableContent.appendChild(section); + } + + // HTML PATH section: render ancestor chain as indented HTML tree (matching CSS selector hover style) + if (attachment.ancestors && attachment.ancestors.length > 1) { + const section = dom.$('div.chat-element-hover-section'); + const header = dom.$('div.chat-element-hover-header', {}, localize('chat.elementHover.htmlPath', "HTML PATH")); + section.appendChild(header); + const lines: string[] = []; + for (let i = 0; i < attachment.ancestors.length; i++) { + const ancestor = attachment.ancestors[i]; + const indent = ' '.repeat(i); + const tag = this.formatAncestorTag(ancestor); + lines.push(`${indent}${tag}`); + } + const pathPre = dom.$('pre.chat-element-hover-code'); + const pathCode = dom.$('code'); + pathCode.textContent = lines.join('\n'); + pathPre.appendChild(pathCode); + section.appendChild(pathPre); + scrollableContent.appendChild(section); + } + + // COMPUTED STYLES section (show key properties to keep hover concise) + const computedStyleEntries = this.getComputedStyleEntriesForHover(attachment.computedStyles); + if (computedStyleEntries.length > 0) { + const section = dom.$('div.chat-element-hover-section'); + const header = dom.$('div.chat-element-hover-header', {}, localize('chat.elementHover.computedStyles', "KEY COMPUTED STYLES")); + section.appendChild(header); + const table = dom.$('div.chat-element-hover-table'); + for (const [name, value] of computedStyleEntries) { + const row = dom.$('div.chat-element-hover-row'); + row.appendChild(dom.$('span.chat-element-hover-label', {}, `${name}:`)); + const valueContainer = dom.$('span.chat-element-hover-value'); + // Show color swatch for color properties + if ((name === 'color' || name === 'background-color') && value) { + const swatch = dom.$('span.chat-element-hover-color-swatch'); + swatch.style.backgroundColor = value; + valueContainer.appendChild(swatch); + } + valueContainer.appendChild(document.createTextNode(value)); + row.appendChild(valueContainer); + table.appendChild(row); + } + section.appendChild(table); + const showMoreButton = dom.$('button.chat-element-hover-show-more', { type: 'button' }, localize('chat.elementHover.showMore', "Show More")); + this._register(dom.addDisposableListener(showMoreButton, dom.EventType.CLICK, async e => { + dom.EventHelper.stop(e, true); + await this.openElementAttachment(attachment); + })); + section.appendChild(showMoreButton); + scrollableContent.appendChild(section); + } + + // POSITION & SIZE section + if (attachment.dimensions) { + const section = dom.$('div.chat-element-hover-section'); + const header = dom.$('div.chat-element-hover-header', {}, localize('chat.elementHover.positionSize', "POSITION & SIZE")); + section.appendChild(header); + const table = dom.$('div.chat-element-hover-table'); + const dims: [string, number][] = [ + ['top:', attachment.dimensions.top], + ['left:', attachment.dimensions.left], + ['width:', attachment.dimensions.width], + ['height:', attachment.dimensions.height], + ]; + for (const [label, val] of dims) { + const row = dom.$('div.chat-element-hover-row'); + row.appendChild(dom.$('span.chat-element-hover-label', {}, label)); + row.appendChild(dom.$('span.chat-element-hover-value', {}, `${Math.round(val)}px`)); + table.appendChild(row); + } + section.appendChild(table); + scrollableContent.appendChild(section); + } + + // INNER TEXT section + if (attachment.innerText) { + const section = dom.$('div.chat-element-hover-section'); + const header = dom.$('div.chat-element-hover-header', {}, localize('chat.elementHover.innerText', "INNER TEXT")); + section.appendChild(header); + section.appendChild(dom.$('div.chat-element-hover-text', {}, attachment.innerText)); + scrollableContent.appendChild(section); + } + + const scrollableElement = this._register(new DomScrollableElement(scrollableContent, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + consumeMouseWheelIfScrollbarIsNeeded: true, + })); + const scrollableDomNode = scrollableElement.getDomNode(); + scrollableDomNode.classList.add('chat-element-hover-scrollable'); + hoverElement.appendChild(scrollableDomNode); + + return { + ...commonHoverOptions, + content: hoverElement, + additionalClasses: ['chat-element-data-hover'], + onDidShow: () => scrollableElement.scanDomNode(), + }; + } + + private shouldRenderRichElementHover(attachment: IElementVariableEntry): boolean { + if (attachment.dimensions || attachment.innerText) { + return true; + } + + if (attachment.ancestors && attachment.ancestors.length > 0) { + return true; + } + + if (attachment.attributes && Object.keys(attachment.attributes).length > 0) { + return true; + } + + if (attachment.computedStyles && Object.keys(attachment.computedStyles).length > 0) { + return true; + } + + return false; + } + + private getSimpleHoverContent(attachment: IElementVariableEntry): IDelayedHoverOptions { + const content = attachment.value?.toString() ?? ''; + const hoverContent = new MarkdownString(); + hoverContent.appendText(attachment.fullName ?? attachment.name); + if (content.trim().length > 0) { + hoverContent.appendMarkdown('\n\n'); + hoverContent.appendCodeblock('text', content); + } + + return { + ...commonHoverOptions, + content: hoverContent, + }; + } + + private getComputedStyleEntriesForHover(computedStyles: Readonly> | undefined): ReadonlyArray<[string, string]> { + if (!computedStyles) { + return []; + } + + const keyEntries: Array<[string, string]> = []; + for (const property of KEY_ELEMENT_HOVER_COMPUTED_STYLE_PROPERTIES) { + if (property === 'margin' || property === 'padding') { + const shorthand = this.getBoxShorthandValue(computedStyles, property); + if (typeof shorthand === 'string') { + keyEntries.push([property, shorthand]); + continue; + } + } + + const value = computedStyles[property]; + if (typeof value === 'string') { + keyEntries.push([property, value]); + } + } + + // Fallback for older payloads that might not include the key properties. + if (keyEntries.length > 0) { + return keyEntries; + } + + return Object.entries(computedStyles).slice(0, KEY_ELEMENT_HOVER_COMPUTED_STYLE_PROPERTIES.length); + } + + private getBoxShorthandValue(computedStyles: Readonly>, propertyName: 'margin' | 'padding'): string | undefined { + const top = computedStyles[`${propertyName}-top`]; + const right = computedStyles[`${propertyName}-right`]; + const bottom = computedStyles[`${propertyName}-bottom`]; + const left = computedStyles[`${propertyName}-left`]; + + if (typeof top === 'string' && typeof right === 'string' && typeof bottom === 'string' && typeof left === 'string') { + return `${top} ${right} ${bottom} ${left}`; + } + + return computedStyles[propertyName]; + } + + private async openElementAttachment(attachment: IElementVariableEntry): Promise { + const content = attachment.value?.toString() || ''; + await this.editorService.openEditor({ + resource: undefined, + contents: content, + options: { + pinned: true + } + }); + } + + private formatElementTag(attachment: IElementVariableEntry): string { + // Extract the opening tag from the outerHTML within the value string + // Value format: "Attached HTML and CSS Context\n\n...\n\n..." + const content = attachment.value?.toString() ?? ''; + const htmlMatch = content.match(/\n\n(<[^>]+>)/); + if (htmlMatch) { + return htmlMatch[1]; + } + // Fallback: try first tag in content + const fallback = content.match(/<([^>]+)>/); + if (fallback) { + return `<${fallback[1]}>`; + } + return `<${attachment.name}>`; + } + + private formatAncestorTag(ancestor: { tagName: string; id?: string; classNames?: string[] }): string { + const parts = [`<${ancestor.tagName}`]; + if (ancestor.classNames?.length) { + parts.push(` class="${ancestor.classNames.join(' ')}"`); + } + if (ancestor.id) { + parts.push(` id="${ancestor.id}"`); + } + return parts.join('') + '>'; + } } export class SCMHistoryItemAttachmentWidget extends AbstractChatAttachmentWidget { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index d15972e2b3a..a270c52b91d 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -298,6 +298,11 @@ class SimpleBrowserOverlayWidget { value: value, kind: 'element', icon: ThemeIcon.fromId(Codicon.layout.id), + ancestors: elementData.ancestors, + attributes: elementData.attributes, + computedStyles: elementData.computedStyles, + dimensions: elementData.dimensions, + innerText: elementData.innerText, }); if (this.configurationService.getValue('chat.sendElementsToChat.attachImages')) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 487e7a8b375..3239c8f271b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2383,6 +2383,131 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-top: 2px; } +/* Element hover (DevTools-style) */ +.chat-element-hover { + max-width: 400px; + min-width: 250px; +} + +.monaco-hover.workbench-hover.chat-element-data-hover .hover-contents.html-hover-contents { + padding: 0; +} + +.monaco-hover.workbench-hover.chat-element-data-hover .chat-element-hover-scrollable { + width: 100%; +} + +.chat-element-hover.chat-attached-context-hover { + padding: 6px 0 6px 6px; +} + +.chat-element-hover-content { + max-height: 350px; + box-sizing: border-box; + padding-right: 10px; +} + +.chat-element-hover .chat-element-hover-section { + padding: 4px 6px; +} + +.chat-element-hover .chat-element-hover-section + .chat-element-hover-section { + border-top: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.2)); +} + +.chat-element-hover .chat-element-hover-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.chat-element-hover .chat-element-hover-code { + margin: 0; + padding: 4px 6px; + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + font-family: var(--monaco-monospace-font); + font-size: 12px; + line-height: 1.5; + white-space: pre; + overflow-x: auto; + color: var(--vscode-editor-foreground); +} + +.chat-element-hover .chat-element-hover-code code { + font-family: inherit; + font-size: inherit; + background: transparent; + padding: 0; + border-radius: 0; + display: block; +} + +.chat-element-hover .chat-element-hover-table { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 8px; +} + +.chat-element-hover .chat-element-hover-row { + display: contents; +} + +.chat-element-hover .chat-element-hover-label { + font-family: var(--monaco-monospace-font); + font-size: 12px; + color: var(--vscode-debugTokenExpression-name); + white-space: nowrap; +} + +.chat-element-hover .chat-element-hover-value { + font-family: var(--monaco-monospace-font); + font-size: 12px; + color: var(--vscode-editor-foreground); + word-break: break-all; + display: flex; + align-items: center; + gap: 4px; +} + +.chat-element-hover .chat-element-hover-color-swatch { + display: inline-block; + width: 12px; + height: 12px; + border: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.4)); + border-radius: 2px; + flex-shrink: 0; +} + +.chat-element-hover .chat-element-hover-text { + font-family: var(--monaco-monospace-font); + font-size: 12px; + color: var(--vscode-editor-foreground); + white-space: pre-wrap; + word-break: break-word; + max-height: 60px; + overflow: hidden; +} + +.chat-element-hover .chat-element-hover-show-more { + background: none; + border: none; + padding: 0; + margin-top: 6px; + color: var(--vscode-textLink-foreground); + cursor: pointer; + font-size: 12px; + text-decoration: underline; +} + +.chat-element-hover .chat-element-hover-show-more:hover, +.chat-element-hover .chat-element-hover-show-more:focus-visible { + color: var(--vscode-textLink-activeForeground); +} + .chat-attached-context-attachment .chat-attached-context-pill { font-size: 12px; diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 58a14a2ad89..5655cc695fd 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -222,8 +222,19 @@ export interface IDiagnosticVariableEntry extends IBaseChatRequestVariableEntry, readonly kind: 'diagnostic'; } +export interface IElementAncestorData { + readonly tagName: string; + readonly id?: string; + readonly classNames?: string[]; +} + export interface IElementVariableEntry extends IBaseChatRequestVariableEntry { readonly kind: 'element'; + readonly ancestors?: IElementAncestorData[]; + readonly attributes?: Record; + readonly computedStyles?: Record; + readonly dimensions?: { readonly top: number; readonly left: number; readonly width: number; readonly height: number }; + readonly innerText?: string; } export interface IPromptFileVariableEntry extends IBaseChatRequestVariableEntry {