diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index abd2873d924b8..1542a50a0a67d 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -31,4 +31,6 @@ export interface INativeBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType, cancellationId?: number): Promise; startDebugSession(token: CancellationToken, browserType: BrowserType, cancelAndDetachId?: number): Promise; + + getConsoleLogs(): Promise; } diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index 404c5d178779c..f53c2113b99dd 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -24,6 +24,18 @@ interface NodeDataResponse { bounds: IRectangle; } +interface LogEntry { + type: 'console' | 'exception' | 'log'; + level?: string; + message?: string; + args?: any[]; + exceptionDetails?: any; + timestamp: number; + rawData: string; +} + +const allConsole = new Map(); + export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService { _serviceBrand: undefined; @@ -141,6 +153,75 @@ export class NativeBrowserElementsMainService extends Disposable implements INat debuggers.attach(); } + let sessionId: string | undefined; + const onMessage = (event: any, method: string, params: any, sessionIdFromMessage?: string) => { + if (sessionIdFromMessage === sessionId && (method === 'Runtime.consoleAPICalled' || method === 'Runtime.exceptionThrown' || method === 'Log.entryAdded')) { + const current = allConsole.get(windowId!) ?? []; + + let logEntry: LogEntry; + + if (method === 'Runtime.consoleAPICalled') { + // Extract console message from args + const args = params.args?.map((arg: any) => { + if (arg.type === 'string') { + return arg.value; + } + if (arg.type === 'object') { + // For objects, try to extract a readable representation + if (arg.preview?.description) { + return arg.preview.description; + } + return JSON.stringify(arg, null, 2); + } + if (arg.value !== undefined) { + return arg.value; + } + return JSON.stringify(arg, null, 2); + }) ?? []; + + logEntry = { + type: 'console', + level: params.type, // 'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear', 'startGroup', 'startGroupCollapsed', 'endGroup' + message: args.join(' '), + args: params.args, + timestamp: Date.now(), + rawData: JSON.stringify(params) + }; + } else if (method === 'Runtime.exceptionThrown') { + const exceptionDetails = params.exceptionDetails; + let errorMessage = exceptionDetails?.text || 'Unknown error'; + if (exceptionDetails?.exception?.description) { + errorMessage = exceptionDetails.exception.description; + } + + logEntry = { + type: 'exception', + level: 'error', + message: errorMessage, + exceptionDetails: params.exceptionDetails, + timestamp: Date.now(), + rawData: JSON.stringify(params) + }; + } else { // Log.entryAdded + logEntry = { + type: 'log', + level: params.level, // 'verbose', 'info', 'warning', 'error' + message: params.text, + timestamp: Date.now(), + rawData: JSON.stringify(params) + }; + } + + if (!current.some(entry => entry.rawData === logEntry.rawData)) { + current.push(logEntry); + if (current.length > 5) { + current.shift(); + } + allConsole.set(windowId!, current); + } + } + }; + try { const matchingTargetId = await this.waitForWebviewTargets(debuggers, windowId!, browserType); if (!matchingTargetId) { @@ -150,6 +231,20 @@ export class NativeBrowserElementsMainService extends Disposable implements INat throw new Error('No target found'); } + // starts console logging as well. + const attachResult = await debuggers.sendCommand('Target.attachToTarget', { + targetId: matchingTargetId, + flatten: true, + }); + sessionId = attachResult.sessionId; + + allConsole.set(windowId!, []); + + await debuggers.sendCommand('Debugger.enable', {}, sessionId); + await debuggers.sendCommand('Runtime.enable', {}, sessionId); + await debuggers.sendCommand('Log.enable', {}, sessionId); + debuggers.on('message', onMessage); + } catch (e) { if (debuggers.isAttached()) { debuggers.detach(); @@ -170,6 +265,53 @@ export class NativeBrowserElementsMainService extends Disposable implements INat } } }); + + window.win.webContents.on('ipc-message', async (event, channel, closedCancelAndDetachId) => { + if (channel === `vscode:changeElementSelection${cancelAndDetachId}`) { + if (cancelAndDetachId !== closedCancelAndDetachId) { + return; + } + if (debuggers.isAttached()) { + debuggers.detach(); + } + debuggers.off('message', onMessage); + if (window.win) { + window.win.webContents.removeAllListeners('ipc-message'); + } + } + }); + } + + async getConsoleLogs(windowId: number | undefined): Promise { + const window = this.windowById(windowId); + if (!window?.win) { + return undefined; + } + + const logs = allConsole.get(window.id) ?? []; + if (logs.length === 0) { + return undefined; + } + + const formatted = logs.map(log => { + let line = ''; + if (log.type === 'console') { + line = `[${log.level?.toUpperCase()}] ${log.message}`; + } else if (log.type === 'exception') { + line = `[ERROR] Exception: ${log.message}`; + if (log.exceptionDetails?.stackTrace?.callFrames?.length) { + line += '\nStack Trace:'; + log.exceptionDetails.stackTrace.callFrames.forEach((frame: any) => { + line += `\n at ${frame.functionName} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`; + }); + } + } else if (log.type === 'log') { + line = `[${log.level?.toUpperCase()}] ${log.message}`; + } + return line; + }).join('\n'); + + return formatted; } async finishOverlay(debuggers: any, sessionId: string | undefined): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts index 0cf1054643dff..b73e9dcb8161d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/simpleBrowserEditorOverlay.ts @@ -145,6 +145,9 @@ class SimpleBrowserOverlayWidget { const cancelButtonLabel = localize('cancelSelectionLabel', 'Cancel'); cancelButton.label = cancelButtonLabel; + const attachLogs = this._showStore.add(new Button(mainContent, { ...defaultButtonStyles, supportIcons: true, title: localize('chat.attachLogs', "Attach Logs") })); + attachLogs.icon = Codicon.bug; + const configure = this._showStore.add(new Button(mainContent, { supportIcons: true, title: localize('chat.configureElements', "Configure Attachments Sent") })); configure.icon = Codicon.gear; @@ -228,6 +231,10 @@ class SimpleBrowserOverlayWidget { this._showStore.add(addDisposableListener(configure.element, 'click', () => { this._preferencesService.openSettings({ jsonEditor: false, query: '@id:chat.sendElementsToChat.enabled,chat.sendElementsToChat.attachCSS,chat.sendElementsToChat.attachImages' }); })); + + this._showStore.add(addDisposableListener(attachLogs.element, 'click', async () => { + await this.addConsolesToChat(); + })); } setActiveBrowserType(type: BrowserType | undefined) { @@ -248,6 +255,23 @@ class SimpleBrowserOverlayWidget { element.classList.remove('hidden'); } + async addConsolesToChat() { + const logs = await this._browserElementsService.getConsoleLogs(); + const toAttach: IChatRequestVariableEntry[] = []; + + toAttach.push({ + id: 'element-' + Date.now(), + name: localize('consoleLogs', 'Console Logs'), + fullName: localize('consoleLogs', 'Console Logs'), + value: logs ?? localize('noConsoleLogs', 'No console logs captured.'), + kind: 'element', + icon: ThemeIcon.fromId(Codicon.bug.id), + }); + + const widget = await showChatView(this._viewService, this._layoutService) ?? this._chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + } + async addElementToChat(cts: CancellationTokenSource) { // eslint-disable-next-line no-restricted-syntax const editorContainer = this._container.querySelector('.editor-container') as HTMLDivElement; @@ -378,6 +402,7 @@ class SimpleBrowserOverlayController { if (activeBrowserType) { try { await this._browserElementsService.startDebugSession(cts.token, activeBrowserType); + } catch (error) { connectingWebviewElement.textContent = localize('reopenErrorWebviewElement', 'Please reopen the preview.'); return; diff --git a/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css b/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css index 3a5e84b1fc9b5..5c573742d04a0 100644 --- a/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/media/simpleBrowserOverlay.css @@ -48,7 +48,8 @@ .element-selection-main-content .monaco-button.codicon.codicon-close, .element-expand-container .monaco-button.codicon.codicon-layout, .element-selection-main-content .monaco-button.codicon.codicon-chevron-right, -.element-selection-main-content .monaco-button.codicon.codicon-gear { +.element-selection-main-content .monaco-button.codicon.codicon-gear, +.element-selection-main-content .monaco-button.codicon.codicon-bug { width: 17px; height: 17px; padding: 2px 2px; diff --git a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts index 4053357623cdb..c47c29333b2cb 100644 --- a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts @@ -17,4 +17,6 @@ export interface IBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType | undefined): Promise; startDebugSession(token: CancellationToken, browserType: BrowserType): Promise; + + getConsoleLogs(): Promise; } diff --git a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts index 7123a7f9b1c0c..349be784b4a98 100644 --- a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts @@ -14,6 +14,10 @@ class WebBrowserElementsService implements IBrowserElementsService { constructor() { } + async getConsoleLogs(): Promise { + throw new Error('Not implemented'); + } + async getElementData(rect: IRectangle, token: CancellationToken): Promise { throw new Error('Not implemented'); } diff --git a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts index b2aae31f50049..1fa6a92d15ef7 100644 --- a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts @@ -68,6 +68,10 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { disposable.dispose(); } } + + async getConsoleLogs(): Promise { + return this.simpleBrowser.getConsoleLogs(); + } } registerSingleton(IBrowserElementsService, WorkbenchBrowserElementsService, InstantiationType.Delayed);