add console to chat from integrated browser (#295839)

* add console to chat from integrated browser

* address some comments and change icon
This commit is contained in:
Justin Chen
2026-02-17 19:10:15 -08:00
committed by GitHub
parent 6b7de4769f
commit 321e4e1f8b
7 changed files with 222 additions and 3 deletions
@@ -46,6 +46,10 @@ export interface INativeBrowserElementsService {
getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise<IElementData | undefined>;
startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise<void>;
startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise<void>;
getConsoleLogs(locator: IBrowserTargetLocator): Promise<string | undefined>;
}
/**
@@ -25,6 +25,17 @@ interface NodeDataResponse {
bounds: IRectangle;
}
const MAX_CONSOLE_LOG_ENTRIES = 1000;
const consoleLogStore = new Map<string, string[]>();
function locatorKey(locator: IBrowserTargetLocator): string {
const key = locator.browserViewId ?? locator.webviewId;
if (!key) {
return 'unknown';
}
return key;
}
export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService {
_serviceBrand: undefined;
@@ -38,11 +49,82 @@ export class NativeBrowserElementsMainService extends Disposable implements INat
get windowId(): never { throw new Error('Not implemented in electron-main'); }
async getConsoleLogs(windowId: number | undefined, locator: IBrowserTargetLocator): Promise<string | undefined> {
const key = locatorKey(locator);
const entries = consoleLogStore.get(key);
if (!entries || entries.length === 0) {
return undefined;
}
return entries.join('\n');
}
async startConsoleSession(windowId: number | undefined, token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise<void> {
const window = this.windowById(windowId);
if (!window?.win) {
return undefined;
}
let targetWebContents: Electron.WebContents | undefined;
if (locator.browserViewId) {
targetWebContents = this.browserViewMainService.tryGetBrowserView(locator.browserViewId)?.webContents;
}
if (!targetWebContents) {
return undefined;
}
const key = locatorKey(locator);
if (!consoleLogStore.has(key)) {
consoleLogStore.set(key, []);
}
const levelMap: Record<number, string> = { 0: 'log', 1: 'warning', 2: 'error' };
const onConsoleMessage = (_event: Electron.Event, level: number, message: string, _line: number, _sourceId: string) => {
const levelName = levelMap[level] ?? 'log';
const formatted = `[${levelName}] ${message}`;
const current = consoleLogStore.get(key) ?? [];
current.push(formatted);
if (current.length > MAX_CONSOLE_LOG_ENTRIES) {
current.splice(0, current.length - MAX_CONSOLE_LOG_ENTRIES);
}
consoleLogStore.set(key, current);
};
const cleanupListeners = () => {
targetWebContents?.off('console-message', onConsoleMessage);
window.win?.webContents.off('ipc-message', onIpcMessage);
};
const onIpcMessage = async (_event: Electron.Event, channel: string, closedCancelAndDetachId: number) => {
if (channel === `vscode:cancelConsoleSession${cancelAndDetachId}`) {
if (cancelAndDetachId !== closedCancelAndDetachId) {
return;
}
cleanupListeners();
consoleLogStore.delete(key);
}
};
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);
}
/**
* Find the webview target that matches the given locator.
* Checks either webviewId or browserViewId depending on what's provided.
*/
async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise<string | undefined> {
private async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise<string | undefined> {
const { targetInfos } = await debuggers.sendCommand('Target.getTargets');
if (locator.webviewId) {
@@ -195,6 +195,7 @@ export class BrowserEditor extends EditorPane {
private readonly _inputDisposables = this._register(new DisposableStore());
private overlayManager: BrowserOverlayManager | undefined;
private _elementSelectionCts: CancellationTokenSource | undefined;
private _consoleSessionCts: CancellationTokenSource | undefined;
private _screenshotTimeout: ReturnType<typeof setTimeout> | undefined;
constructor(
@@ -367,6 +368,12 @@ export class BrowserEditor extends EditorPane {
// Update navigation bar and context keys from model
this.updateNavigationState(navEvent);
if (navEvent.url) {
this.startConsoleSession();
} else {
this.stopConsoleSession();
}
}));
this._inputDisposables.add(this._model.onDidChangeLoadingState(() => {
@@ -759,6 +766,66 @@ export class BrowserEditor extends EditorPane {
}
}
async addConsoleLogsToChat(): Promise<void> {
const resourceUri = this.input?.resource;
if (!resourceUri) {
return;
}
const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) };
try {
const logs = await this.browserElementsService.getConsoleLogs(locator);
if (!logs) {
return;
}
const toAttach: IChatRequestVariableEntry[] = [];
toAttach.push({
id: 'console-logs-' + Date.now(),
name: localize('consoleLogs', 'Console Logs'),
fullName: localize('consoleLogs', 'Console Logs'),
value: logs,
kind: 'element',
icon: ThemeIcon.fromId(Codicon.output.id),
});
const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget;
widget?.attachmentModel?.addContext(...toAttach);
} catch (error) {
this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error);
}
}
private startConsoleSession(): void {
// don't restart if already running
if (this._consoleSessionCts) {
return;
}
const resourceUri = this.input?.resource;
if (!resourceUri || !this._model?.url) {
return;
}
const cts = new CancellationTokenSource();
this._consoleSessionCts = cts;
const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) };
this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => {
if (!cts.token.isCancellationRequested) {
this.logService.error('BrowserEditor: Failed to start console session', error);
}
});
}
private stopConsoleSession(): void {
if (this._consoleSessionCts) {
this._consoleSessionCts.dispose(true);
this._consoleSessionCts = undefined;
}
}
/**
* Update navigation state and context keys
*/
@@ -907,6 +974,9 @@ export class BrowserEditor extends EditorPane {
this._elementSelectionCts = undefined;
}
// Cancel any active console session
this.stopConsoleSession();
// Cancel any scheduled screenshots
this.cancelScheduledScreenshot();
@@ -254,6 +254,34 @@ class AddElementToChatAction extends Action2 {
}
}
class AddConsoleLogsToChatAction extends Action2 {
static readonly ID = 'workbench.action.browser.addConsoleLogsToChat';
constructor() {
const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true));
super({
id: AddConsoleLogsToChatAction.ID,
title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'),
category: BrowserCategory,
icon: Codicon.output,
f1: true,
precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, enabled),
menu: {
id: MenuId.BrowserActionsToolbar,
group: 'actions',
order: 2,
when: enabled
}
});
}
async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise<void> {
if (browserEditor instanceof BrowserEditor) {
await browserEditor.addConsoleLogsToChat();
}
}
}
class ToggleDevToolsAction extends Action2 {
static readonly ID = 'workbench.action.browser.toggleDevTools';
@@ -269,7 +297,7 @@ class ToggleDevToolsAction extends Action2 {
menu: {
id: MenuId.BrowserActionsToolbar,
group: 'actions',
order: 2,
order: 3,
},
keybinding: {
weight: KeybindingWeight.WorkbenchContrib,
@@ -542,6 +570,7 @@ registerAction2(GoForwardAction);
registerAction2(ReloadAction);
registerAction2(FocusUrlInputAction);
registerAction2(AddElementToChatAction);
registerAction2(AddConsoleLogsToChatAction);
registerAction2(ToggleDevToolsAction);
registerAction2(OpenInExternalBrowserAction);
registerAction2(ClearGlobalBrowserStorageAction);
@@ -17,4 +17,8 @@ export interface IBrowserElementsService {
getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise<IElementData | undefined>;
startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise<void>;
startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise<void>;
getConsoleLogs(locator: IBrowserTargetLocator): Promise<string | undefined>;
}
@@ -21,6 +21,14 @@ class WebBrowserElementsService implements IBrowserElementsService {
async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise<void> {
throw new Error('Not implemented');
}
async startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise<void> {
throw new Error('Not implemented');
}
async getConsoleLogs(locator: IBrowserTargetLocator): Promise<string | undefined> {
throw new Error('Not implemented');
}
}
registerSingleton(IBrowserElementsService, WebBrowserElementsService, InstantiationType.Delayed);
@@ -33,6 +33,27 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService {
@INativeBrowserElementsService private readonly simpleBrowser: INativeBrowserElementsService
) { }
async getConsoleLogs(locator: IBrowserTargetLocator): Promise<string | undefined> {
return await this.simpleBrowser.getConsoleLogs(locator);
}
async startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise<void> {
const cancelAndDetachId = cancelAndDetachIdPool++;
const onCancelChannel = `vscode:cancelConsoleSession${cancelAndDetachId}`;
const disposable = token.onCancellationRequested(() => {
ipcRenderer.send(onCancelChannel, cancelAndDetachId);
disposable.dispose();
});
try {
await this.simpleBrowser.startConsoleSession(token, locator, cancelAndDetachId);
} catch (error) {
throw new Error('Failed to start console session', { cause: error });
} finally {
disposable.dispose();
}
}
async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise<void> {
const cancelAndDetachId = cancelAndDetachIdPool++;
const onCancelChannel = `vscode:cancelCurrentSession${cancelAndDetachId}`;
@@ -44,8 +65,9 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService {
try {
await this.simpleBrowser.startDebugSession(token, locator, cancelAndDetachId);
} catch (error) {
throw new Error('No debug session target found', { cause: error });
} finally {
disposable.dispose();
throw new Error('No debug session target found', error);
}
}