Skip to content

Commit d228467

Browse files
authored
Fix flickering when using browser screenshot tool (#298080)
* Fix flickering when using browser screenshot tool * feedback * fix * feedback
1 parent 77ec174 commit d228467

File tree

9 files changed

+118
-62
lines changed

9 files changed

+118
-62
lines changed

src/vs/platform/browserView/common/browserView.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface IBrowserViewState {
3434
lastFavicon: string | undefined;
3535
lastError: IBrowserViewLoadError | undefined;
3636
storageScope: BrowserViewStorageScope;
37+
zoomFactor: number;
3738
}
3839

3940
export interface IBrowserViewNavigationEvent {
@@ -153,6 +154,14 @@ export interface IBrowserViewService {
153154
*/
154155
destroyBrowserView(id: string): Promise<void>;
155156

157+
/**
158+
* Get the state of an existing browser view by ID, or throw if it doesn't exist
159+
* @param id The browser view identifier
160+
* @return The state of the browser view for the given ID
161+
* @throws If no browser view exists for the given ID
162+
*/
163+
getState(id: string): Promise<IBrowserViewState>;
164+
156165
/**
157166
* Update the bounds of a browser view
158167
* @param id The browser view identifier

src/vs/platform/browserView/common/playwrightService.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Event } from '../../../base/common/event.js';
7-
import { VSBuffer } from '../../../base/common/buffer.js';
87
import { createDecorator } from '../../instantiation/common/instantiation.js';
98

109
export const IPlaywrightService = createDecorator<IPlaywrightService>('playwrightService');
@@ -63,23 +62,24 @@ export interface IPlaywrightService {
6362
getSummary(pageId: string): Promise<string>;
6463

6564
/**
66-
* Run a function with access to a Playwright page.
65+
* Run a function with access to a Playwright page and return its raw result, or throw an error.
6766
* The first function argument is always the Playwright `page` object, and additional arguments can be passed after.
6867
* @param pageId The browser view ID identifying the page to operate on.
6968
* @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`.
7069
* @param args Additional arguments to pass to the function after the `page` object.
71-
* @returns The result of the function execution, including a page summary.
70+
* @returns The result of the function execution.
7271
*/
73-
invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>;
72+
invokeFunctionRaw<T>(pageId: string, fnDef: string, ...args: unknown[]): Promise<T>;
7473

7574
/**
76-
* Takes a screenshot of the current page viewport and returns it as a VSBuffer.
77-
* @param pageId The browser view ID identifying the page to capture.
78-
* @param selector Optional Playwright selector to capture a specific element instead of the viewport.
79-
* @param fullPage Whether to capture the full scrollable page instead of just the viewport.
80-
* @returns The screenshot image data.
75+
* Run a function with access to a Playwright page and return a result for tool output, including error handling.
76+
* The first function argument is always the Playwright `page` object, and additional arguments can be passed after.
77+
* @param pageId The browser view ID identifying the page to operate on.
78+
* @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`.
79+
* @param args Additional arguments to pass to the function after the `page` object.
80+
* @returns The result of the function execution, including a page summary.
8181
*/
82-
captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise<VSBuffer>;
82+
invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>;
8383

8484
/**
8585
* Responds to a file chooser dialog on the given page.

src/vs/platform/browserView/electron-main/browserView.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ export class BrowserView extends Disposable implements ICDPTarget {
359359
lastScreenshot: this._lastScreenshot,
360360
lastFavicon: this._lastFavicon,
361361
lastError: this._lastError,
362-
storageScope: this.session.storageScope
362+
storageScope: this.session.storageScope,
363+
zoomFactor: webContents.getZoomFactor()
363364
};
364365
}
365366

@@ -509,13 +510,6 @@ export class BrowserView extends Disposable implements ICDPTarget {
509510
}
510511
}
511512

512-
/**
513-
* Set the zoom factor of this view
514-
*/
515-
async setZoomFactor(zoomFactor: number): Promise<void> {
516-
await this._view.webContents.setZoomFactor(zoomFactor);
517-
}
518-
519513
/**
520514
* Focus this view
521515
*/

src/vs/platform/browserView/electron-main/browserViewMainService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
278278
return this._getBrowserView(id).onDidClose;
279279
}
280280

281+
async getState(id: string): Promise<IBrowserViewState> {
282+
return this._getBrowserView(id).getState();
283+
}
284+
281285
async destroyBrowserView(id: string): Promise<void> {
282286
return this.browserViews.deleteAndDispose(id);
283287
}
@@ -330,10 +334,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
330334
return this._getBrowserView(id).dispatchKeyEvent(keyEvent);
331335
}
332336

333-
async setZoomFactor(id: string, zoomFactor: number): Promise<void> {
334-
return this._getBrowserView(id).setZoomFactor(zoomFactor);
335-
}
336-
337337
async focus(id: string): Promise<void> {
338338
return this._getBrowserView(id).focus();
339339
}

src/vs/platform/browserView/node/playwrightService.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { ILogService } from '../../log/common/log.js';
1010
import { IPlaywrightService } from '../common/playwrightService.js';
1111
import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js';
1212
import { IBrowserViewGroup } from '../common/browserViewGroup.js';
13-
import { VSBuffer } from '../../../base/common/buffer.js';
1413
import { PlaywrightTab } from './playwrightTab.js';
1514

1615
// eslint-disable-next-line local/code-import-patterns
@@ -125,18 +124,22 @@ export class PlaywrightService extends Disposable implements IPlaywrightService
125124
return this._pages.getSummary(pageId, true);
126125
}
127126

127+
async invokeFunctionRaw<T>(pageId: string, fnDef: string, ...args: unknown[]): Promise<T> {
128+
await this.initialize();
129+
130+
const vm = await import('vm');
131+
const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() });
132+
133+
return this._pages.runAgainstPage(pageId, (page) => fn(page, args));
134+
}
135+
128136
async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> {
129137
this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`);
130138

131139
try {
132-
await this.initialize();
133-
134-
const vm = await import('vm');
135-
const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() });
136-
137140
let result;
138141
try {
139-
result = await this._pages.runAgainstPage(pageId, (page) => fn(page, args));
142+
result = await this.invokeFunctionRaw(pageId, fnDef, ...args);
140143
} catch (err: unknown) {
141144
result = err instanceof Error ? err.message : String(err);
142145
}
@@ -155,16 +158,6 @@ export class PlaywrightService extends Disposable implements IPlaywrightService
155158
}
156159
}
157160

158-
async captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise<VSBuffer> {
159-
await this.initialize();
160-
return this._pages.runAgainstPage(pageId, async page => {
161-
const screenshotBuffer = selector
162-
? await page.locator(selector).screenshot({ type: 'jpeg', quality: 80 })
163-
: await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false });
164-
return VSBuffer.wrap(screenshotBuffer);
165-
});
166-
}
167-
168161
async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> {
169162
await this.initialize();
170163
const summary = await this._pages.replyToFileChooser(pageId, files);

src/vs/workbench/contrib/browserView/common/browserView.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ export interface IBrowserViewWorkbenchService {
7676
*/
7777
getOrCreateBrowserViewModel(id: string): Promise<IBrowserViewModel>;
7878

79+
/**
80+
* Get an existing browser view model for the given ID
81+
* @param id The browser view identifier
82+
* @returns A browser view model that proxies to the main process
83+
* @throws If no browser view exists for the given ID
84+
*/
85+
getBrowserViewModel(id: string): Promise<IBrowserViewModel>;
86+
7987
/**
8088
* Clear all storage data for the global browser session
8189
*/
@@ -105,9 +113,9 @@ export interface IBrowserViewModel extends IDisposable {
105113
readonly isDevToolsOpen: boolean;
106114
readonly canGoForward: boolean;
107115
readonly error: IBrowserViewLoadError | undefined;
108-
109116
readonly storageScope: BrowserViewStorageScope;
110117
readonly sharedWithAgent: boolean;
118+
readonly zoomFactor: number;
111119

112120
readonly onDidChangeSharedWithAgent: Event<boolean>;
113121
readonly onDidNavigate: Event<IBrowserViewNavigationEvent>;
@@ -123,7 +131,7 @@ export interface IBrowserViewModel extends IDisposable {
123131
readonly onDidClose: Event<void>;
124132
readonly onWillDispose: Event<void>;
125133

126-
initialize(): Promise<void>;
134+
initialize(create: boolean): Promise<void>;
127135

128136
layout(bounds: IBrowserViewBounds): Promise<void>;
129137
setVisible(visible: boolean): Promise<void>;
@@ -156,6 +164,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
156164
private _error: IBrowserViewLoadError | undefined = undefined;
157165
private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral;
158166
private _sharedWithAgent: boolean = false;
167+
private _zoomFactor: number = 1;
159168

160169
private readonly _onDidChangeSharedWithAgent = this._register(new Emitter<boolean>());
161170
readonly onDidChangeSharedWithAgent: Event<boolean> = this._onDidChangeSharedWithAgent.event;
@@ -190,6 +199,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
190199
get error(): IBrowserViewLoadError | undefined { return this._error; }
191200
get storageScope(): BrowserViewStorageScope { return this._storageScope; }
192201
get sharedWithAgent(): boolean { return this._sharedWithAgent; }
202+
get zoomFactor(): number { return this._zoomFactor; }
193203

194204
get onDidNavigate(): Event<IBrowserViewNavigationEvent> {
195205
return this.browserViewService.onDynamicDidNavigate(this.id);
@@ -236,9 +246,11 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
236246
}
237247

238248
/**
239-
* Initialize the model with the current state from the main process
249+
* Initialize the model with the current state from the main process.
250+
* @param create Whether to create the browser view if it doesn't already exist.
251+
* @throws If the browser view doesn't exist and `create` is false, or if initialization fails
240252
*/
241-
async initialize(): Promise<void> {
253+
async initialize(create: boolean): Promise<void> {
242254
const dataStorageSetting = this.configurationService.getValue<BrowserViewStorageScope>(
243255
'workbench.browser.dataStorage'
244256
) ?? BrowserViewStorageScope.Global;
@@ -253,7 +265,9 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
253265
const dataStorage = isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting;
254266

255267
const workspaceId = this.workspaceContextService.getWorkspace().id;
256-
const state = await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId);
268+
const state = create
269+
? await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId)
270+
: await this.browserViewService.getState(this.id);
257271

258272
this._url = state.url;
259273
this._title = state.title;
@@ -268,6 +282,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
268282
this._error = state.lastError;
269283
this._storageScope = state.storageScope;
270284
this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id);
285+
this._zoomFactor = state.zoomFactor;
271286

272287
// Set up state synchronization
273288

@@ -314,6 +329,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
314329
}
315330

316331
async layout(bounds: IBrowserViewBounds): Promise<void> {
332+
this._zoomFactor = bounds.zoomFactor;
317333
return this.browserViewService.layout(this.id, bounds);
318334
}
319335

src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService
2727
}
2828

2929
async getOrCreateBrowserViewModel(id: string): Promise<IBrowserViewModel> {
30+
return this._getBrowserViewModel(id, true);
31+
}
32+
33+
async getBrowserViewModel(id: string): Promise<IBrowserViewModel> {
34+
return this._getBrowserViewModel(id, false);
35+
}
36+
37+
async clearGlobalStorage(): Promise<void> {
38+
return this._browserViewService.clearGlobalStorage();
39+
}
40+
41+
async clearWorkspaceStorage(): Promise<void> {
42+
const workspaceId = this.workspaceContextService.getWorkspace().id;
43+
return this._browserViewService.clearWorkspaceStorage(workspaceId);
44+
}
45+
46+
private async _getBrowserViewModel(id: string, create: boolean): Promise<IBrowserViewModel> {
3047
let model = this._models.get(id);
3148
if (model) {
3249
return model;
@@ -36,7 +53,12 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService
3653
this._models.set(id, model);
3754

3855
// Initialize the model with current state
39-
await model.initialize();
56+
try {
57+
await model.initialize(create);
58+
} catch (e) {
59+
this._models.delete(id);
60+
throw e;
61+
}
4062

4163
// Clean up model when disposed
4264
Event.once(model.onWillDispose)(() => {
@@ -45,13 +67,4 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService
4567

4668
return model;
4769
}
48-
49-
async clearGlobalStorage(): Promise<void> {
50-
return this._browserViewService.clearGlobalStorage();
51-
}
52-
53-
async clearWorkspaceStorage(): Promise<void> {
54-
const workspaceId = this.workspaceContextService.getWorkspace().id;
55-
return this._browserViewService.clearWorkspaceStorage(workspaceId);
56-
}
5770
}

src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import { IToolResult } from '../../../chat/common/tools/languageModelToolsServic
99
// eslint-disable-next-line local/code-import-patterns
1010
import type { Page } from 'playwright-core';
1111

12+
/**
13+
* Shared helper for running a Playwright function against a page and returning its result.
14+
*/
15+
export async function playwrightInvokeRaw<TArgs extends unknown[], TReturn>(
16+
playwrightService: IPlaywrightService,
17+
pageId: string,
18+
fn: (page: Page, ...args: TArgs) => Promise<TReturn>,
19+
...args: TArgs
20+
): Promise<TReturn> {
21+
return playwrightService.invokeFunctionRaw(pageId, fn.toString(), ...args);
22+
}
23+
1224
/**
1325
* Shared helper for running a Playwright function against a page and returning
1426
* a tool result. Handles success/error formatting.

src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { Codicon } from '../../../../../base/common/codicons.js';
88
import { localize } from '../../../../../nls.js';
99
import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js';
1010
import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js';
11-
import { errorResult } from './browserToolHelpers.js';
11+
import { IBrowserViewWorkbenchService } from '../../common/browserView.js';
12+
import { errorResult, playwrightInvokeRaw } from './browserToolHelpers.js';
1213
import { OpenPageToolId } from './openBrowserTool.js';
1314
import { ReadBrowserToolData } from './readBrowserTool.js';
1415

@@ -29,16 +30,16 @@ export const ScreenshotBrowserToolData: IToolData = {
2930
},
3031
selector: {
3132
type: 'string',
32-
description: 'Playwright selector of an element to capture. If omitted, captures the whole page.'
33+
description: 'Playwright selector of an element to capture. If omitted, captures the whole viewport.'
3334
},
3435
ref: {
3536
type: 'string',
36-
description: 'Element reference to capture. If omitted, captures the whole page.'
37+
description: 'Element reference to capture. If omitted, captures the whole viewport.'
3738
},
38-
fullPage: {
39+
scrollIntoViewIfNeeded: {
3940
type: 'boolean',
40-
description: 'Set to true to capture the full scrollable page instead of just the viewport. Incompatible with selector/ref.'
41-
},
41+
description: 'Whether to scroll the element into view before capturing. Defaults to false.',
42+
}
4243
},
4344
required: ['pageId'],
4445
},
@@ -48,11 +49,12 @@ interface IScreenshotBrowserToolParams {
4849
pageId: string;
4950
selector?: string;
5051
ref?: string;
51-
fullPage?: boolean;
52+
scrollIntoViewIfNeeded?: boolean;
5253
}
5354

5455
export class ScreenshotBrowserTool implements IToolImpl {
5556
constructor(
57+
@IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService,
5658
@IPlaywrightService private readonly playwrightService: IPlaywrightService,
5759
) { }
5860

@@ -75,7 +77,24 @@ export class ScreenshotBrowserTool implements IToolImpl {
7577
selector = `aria-ref=${params.ref}`;
7678
}
7779

78-
const screenshot = await this.playwrightService.captureScreenshot(params.pageId, selector, params.fullPage);
80+
// Note that we don't use Playwright's screenshot methods because they cause brief flashing on the page,
81+
// and also doesn't handle zooming well.
82+
const browserViewModel = await this.browserViewWorkbenchService.getBrowserViewModel(params.pageId); // Throws if the given pageId doesn't exist
83+
const bounds = selector && await playwrightInvokeRaw(this.playwrightService, params.pageId, async (page, selector, scrollIntoViewIfNeeded) => {
84+
const locator = page.locator(selector);
85+
if (scrollIntoViewIfNeeded) {
86+
await locator.scrollIntoViewIfNeeded();
87+
}
88+
return locator.boundingBox();
89+
}, selector, params.scrollIntoViewIfNeeded) || undefined;
90+
const zoomFactor = browserViewModel.zoomFactor;
91+
if (bounds) {
92+
bounds.x *= zoomFactor;
93+
bounds.y *= zoomFactor;
94+
bounds.width *= zoomFactor;
95+
bounds.height *= zoomFactor;
96+
}
97+
const screenshot = await browserViewModel.captureScreenshot({ rect: bounds });
7998

8099
return {
81100
content: [

0 commit comments

Comments
 (0)