diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 218acce24fd..5f6b0c82414 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -46,6 +46,10 @@ export interface INativeBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise; startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; + + startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; + + getConsoleLogs(locator: IBrowserTargetLocator): Promise; } /** diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index fbf52a2a064..4e53a021745 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -25,6 +25,17 @@ interface NodeDataResponse { bounds: IRectangle; } +const MAX_CONSOLE_LOG_ENTRIES = 1000; +const consoleLogStore = new Map(); + +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 { + 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 { + 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 = { 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 { + private async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { const { targetInfos } = await debuggers.sendCommand('Target.getTargets'); if (locator.webviewId) { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ad793c2770e..cea4ababe30 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -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 | 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 { + 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(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index d1bd6f3d4c8..dd212d04485 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -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 { + 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); diff --git a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts index 7e7ae683bff..0f56a1edf3a 100644 --- a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts @@ -17,4 +17,8 @@ export interface IBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise; startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise; + + startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise; + + getConsoleLogs(locator: IBrowserTargetLocator): Promise; } diff --git a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts index 337987925cb..a98c4de1ba5 100644 --- a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts @@ -21,6 +21,14 @@ class WebBrowserElementsService implements IBrowserElementsService { async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { throw new Error('Not implemented'); } + + async startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { + throw new Error('Not implemented'); + } + + async getConsoleLogs(locator: IBrowserTargetLocator): Promise { + throw new Error('Not implemented'); + } } registerSingleton(IBrowserElementsService, WebBrowserElementsService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts index 021dad4e4c9..fa2ddc77288 100644 --- a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts @@ -33,6 +33,27 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { @INativeBrowserElementsService private readonly simpleBrowser: INativeBrowserElementsService ) { } + async getConsoleLogs(locator: IBrowserTargetLocator): Promise { + return await this.simpleBrowser.getConsoleLogs(locator); + } + + async startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { + 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 { 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); } }