mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-15 04:41:00 +01:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+125
-25
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user