cool hover for integrated browser added elements (#295963)

* cool hover for integrated browser added elements

* some fixes and addressing comments

* cleanup
This commit is contained in:
Justin Chen
2026-02-18 15:57:37 -08:00
committed by GitHub
parent 81f126280a
commit 2deb8a7d5d
7 changed files with 679 additions and 41 deletions
@@ -9,10 +9,21 @@ import { IRectangle } from '../../window/common/window.js';
export const INativeBrowserElementsService = createDecorator<INativeBrowserElementsService>('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<string, string>;
readonly computedStyles?: Record<string, string>;
readonly dimensions?: { readonly top: number; readonly left: number; readonly width: number; readonly height: number };
readonly innerText?: string;
}
/**
@@ -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<string, string>;
computedStyles?: Record<string, string>;
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<string, string[]>();
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<string | undefined> {
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<NodeDataResponse> {
@@ -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<string, string> | undefined;
let computedStyles: Record<string, string> | 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);
@@ -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<boolean>('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<void> {
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<Record<string, string>> | 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<string, string>, 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
*/
@@ -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<Record<string, string>> | 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<Record<string, string>>, 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<void> {
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<tag ...>...</tag>\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 {
@@ -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')) {
@@ -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;
@@ -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<string, string>;
readonly computedStyles?: Record<string, string>;
readonly dimensions?: { readonly top: number; readonly left: number; readonly width: number; readonly height: number };
readonly innerText?: string;
}
export interface IPromptFileVariableEntry extends IBaseChatRequestVariableEntry {