fix(browser): preserve chat focus during agent browsing

This commit is contained in:
2026-09-27 15:23:23 +08:00
parent 220e89992b
commit a52e747a00
10 changed files with 323 additions and 16 deletions

View File

@@ -1280,7 +1280,9 @@ describe('AgentBrowserModule', () => {
await expect(module.sendCdp({
projectPath,
method: 'Runtime.evaluate',
})).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' });
})).resolves.toMatchObject({ kind: 'inline' });
await expect(module.readEvents({ projectPath, after: 0 })).resolves.toBeDefined();
await module.navigate({ projectPath, action: 'reload' });
const reopened = await module.open({
projectId: 'clock',
projectPath,
@@ -1288,10 +1290,18 @@ describe('AgentBrowserModule', () => {
visible: true,
});
expect(reopened).toMatchObject({
browserId: first.browserId,
generation: first.generation,
visible: false,
bounds: { x: 1, y: 2, width: 640, height: 480 },
});
expect(adapter.views[0].visible).toBe(false);
expect(adapter.views).toHaveLength(1);
await module.close(projectPath);
await expect(module.sendCdp({
projectPath,
method: 'Runtime.evaluate',
})).rejects.toMatchObject({ code: 'BROWSER_NOT_OPEN' });
});
it('waits for the matching browser generation to be presented without polling', async () => {

View File

@@ -32,6 +32,7 @@ const electronMocks = vi.hoisted(() => {
webContents: unknown;
setBounds: ReturnType<typeof vi.fn>;
setVisible: ReturnType<typeof vi.fn>;
getVisible: ReturnType<typeof vi.fn>;
}> = [];
const permissionHandler = vi.fn();
const permissionCheckHandler = vi.fn();
@@ -66,11 +67,13 @@ const electronMocks = vi.hoisted(() => {
isDevToolsOpened: vi.fn().mockReturnValue(false),
reload: vi.fn(),
executeJavaScript: vi.fn().mockResolvedValue({ ok: true }),
capturePage: vi.fn().mockResolvedValue({}),
close: vi.fn(),
setWindowOpenHandler: vi.fn(),
});
readonly setBounds = vi.fn();
readonly setVisible = vi.fn();
readonly getVisible = vi.fn().mockReturnValue(true);
constructor(readonly options: unknown) {
nativeViews.push(this);
@@ -102,6 +105,29 @@ describe('ElectronAgentBrowserAdapter', () => {
electronMocks.browserSession.removeAllListeners();
});
it.each([false, true])('captures a page without presenting a hidden native view (visible=%s)', async (visible) => {
const adapter = new ElectronAgentBrowserAdapter({} as never);
const view = adapter.createView('browser-screenshot');
const native = electronMocks.nativeViews[0];
native.getVisible.mockReturnValue(visible);
const contents = native.webContents as InstanceType<typeof electronMocks.MockWebContentsView>['webContents'];
const params = { format: 'jpeg', quality: 80, clip: { x: 0, y: 0, width: 320, height: 200, scale: 1 } };
contents.debugger.sendCommand.mockResolvedValue({ data: 'screenshot' });
await expect(view.webContents.debugger.sendCommand('Page.captureScreenshot', params, 'child-session'))
.resolves.toEqual({ data: 'screenshot' });
if (visible) {
expect(contents.capturePage).not.toHaveBeenCalled();
} else {
expect(contents.capturePage).toHaveBeenCalledExactlyOnceWith(undefined, { stayHidden: true, stayAwake: false });
expect(contents.capturePage.mock.invocationCallOrder[0]).toBeLessThan(contents.debugger.sendCommand.mock.invocationCallOrder[0]);
}
expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', params, 'child-session');
expect(native.setVisible).not.toHaveBeenCalled();
contents.capturePage.mockClear();
await view.webContents.debugger.sendCommand('Runtime.evaluate', { expression: '1' });
expect(contents.capturePage).not.toHaveBeenCalled();
});
it('creates an isolated sandboxed WebContentsView and mounts it in Main', async () => {
const addChildView = vi.fn();
const removeChildView = vi.fn();

View File

@@ -458,6 +458,45 @@ describe('PI-090 product tools', () => {
});
});
it.each([false, true])('preserves the selected tab when reopening an initialized browser (visible=%s)', async (visible) => {
const root = await temporaryRoot('makelore-pi-browser-reuse-');
const initialized = {
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
state: 'attached' as const, generation: 3, url: 'http://127.0.0.1:4173/', title: 'App',
visible, bounds: { x: 10, y: 20, width: 800, height: 600 },
canGoBack: false, canGoForward: false, eventCursor: 0,
};
const browser = {
open: vi.fn(async () => initialized),
waitForPresentation: vi.fn(async () => ({ ...initialized, visible: true })),
setDiagnostics: vi.fn(async () => initialized),
} as unknown as AgentBrowserModule;
const requestAgentBrowserPresentation = vi.fn();
const publishAgentBrowserState = vi.fn();
const tools = new PiProductTools({
browser,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
requestAgentBrowserPresentation,
publishAgentBrowserState,
});
const context = {
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
projectId: 'project-a', projectPath: root, skillIds: [],
};
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await tools.execute('agent_browser', context, { action: 'open', url: initialized.url });
expect(JSON.parse(result.content[0].text)).toMatchObject({ visible, generation: 3 });
}
expect(requestAgentBrowserPresentation).not.toHaveBeenCalled();
expect(browser.waitForPresentation).not.toHaveBeenCalled();
expect(publishAgentBrowserState).toHaveBeenLastCalledWith(initialized);
await tools.settleRun('conversation-a', 'run-a');
expect(browser.setDiagnostics).toHaveBeenCalledWith({
projectPath: root, enabled: false, owner: 'agent:conversation-a:run-a',
});
});
it('closes an agent-opened browser when presentation times out', async () => {
const root = await temporaryRoot('makelore-pi-browser-timeout-');
const hidden = {