Keep agent browser tabs connected across workspace switches (#2156)

* fix(desktop): keep browser tabs connected across workspaces

Electron can replace a guest WebContents when a retained browser tab is reparented. Re-register each attachment and keep background actionability checks running so agent browser tools retain the tab through workspace eviction.\n\nCover the full app, daemon, Electron, and MCP path in the existing desktop CI job.

* fix(desktop): launch Electron E2E reliably on Linux

CI Electron must receive --no-sandbox before the app starts because the hosted runner cannot use Electron's bundled SUID sandbox helper. Forward explicit dev-runner arguments and fail readiness waits as soon as a child exits.

* fix(desktop): preserve active browser on repeated registration

* fix(desktop): keep browser keyboard attachment idempotent

* fix(desktop): wait for E2E bridge readiness

* fix(desktop): close E2E logs after output drains
This commit is contained in:
Mohamed Boudra
2026-07-17 19:02:09 +08:00
committed by GitHub
parent 557fc42c89
commit 388f1d426c
12 changed files with 601 additions and 39 deletions
@@ -100,8 +100,11 @@ function buildActionabilityScript(input: {
const deadline = performance.now() + ${JSON.stringify(input.timeoutMs)};
const requiresEditable = ${JSON.stringify(input.editable)};
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Electron can suspend requestAnimationFrame while a guest is parked after
// workspace LRU eviction even though the guest remains CDP-controllable.
// Timed layout samples keep background browser automation live.
const waitForLayout = () => sleep(16);
const nearlyEqual = (a, b) => Math.abs(a - b) < 0.25;
const sameRect = (a, b) =>
nearlyEqual(a.x, b.x) &&
@@ -174,9 +177,9 @@ function buildActionabilityScript(input: {
}
element.scrollIntoView?.({ block: 'center', inline: 'center' });
await nextFrame();
await waitForLayout();
const firstRect = element.getBoundingClientRect();
await nextFrame();
await waitForLayout();
const secondRect = element.getBoundingClientRect();
if (!sameRect(firstRect, secondRect)) {
detail = 'moving';
@@ -21,11 +21,11 @@ class FakeBrowserContents {
public readonly sent: SentMessage[] = [];
private destroyed = false;
private readonly destroyedListeners: Array<() => void> = [];
private domReadyListener: (() => void) | null = null;
private readonly domReadyListeners: Array<() => void> = [];
private finishLoadListener: (() => void) | null = null;
private inputListener:
| ((event: { preventDefault(): void }, input: Electron.Input) => void)
| null = null;
private readonly inputListeners: Array<
(event: { preventDefault(): void }, input: Electron.Input) => void
> = [];
public constructor(private readonly webContentsId: number) {}
@@ -64,13 +64,12 @@ class FakeBrowserContents {
return;
}
if (event === "dom-ready") {
this.domReadyListener = listener as () => void;
this.domReadyListeners.push(listener as () => void);
return;
}
this.inputListener = listener as (
event: { preventDefault(): void },
input: Electron.Input,
) => void;
this.inputListeners.push(
listener as (event: { preventDefault(): void }, input: Electron.Input) => void,
);
}
public send(channel: string, payload: unknown): void {
@@ -105,19 +104,23 @@ class FakeBrowserContents {
}
public domReady(): void {
this.domReadyListener?.();
for (const listener of this.domReadyListeners) {
listener();
}
}
public input(input: Electron.Input): boolean {
let wasPrevented = false;
this.inputListener?.(
{
preventDefault: () => {
wasPrevented = true;
for (const listener of this.inputListeners) {
listener(
{
preventDefault: () => {
wasPrevented = true;
},
},
},
input,
);
input,
);
}
return wasPrevented;
}
}
@@ -196,6 +199,20 @@ describe("BrowserKeyboard", () => {
]);
});
test("handles a reserved shortcut once when the same guest attaches again", () => {
const { attach } = createBrowserKeyboard();
const guest = new FakeBrowserContents(53);
const host = new FakeBrowserContents(54);
attach({ browserId: "browser-a", contents: guest, hostContents: host });
attach({ browserId: "browser-a", contents: guest, hostContents: host });
const command = process.platform === "darwin" ? { meta: true } : { control: true };
const wasPrevented = guest.input(electronInput({ ...command, code: "KeyR", key: "r" }));
expect(wasPrevented).toBe(true);
expect(guest.reloads).toEqual(["reload"]);
});
test("republishes the latest shortcut policy when the next guest document is ready", () => {
const { attach, keyboard } = createBrowserKeyboard();
const guest = new FakeBrowserContents(61);
@@ -87,6 +87,13 @@ export class BrowserKeyboard {
if (!registration || registration.hostWebContentsId !== input.hostContents.id) {
return;
}
const attachedGuest = this.attachedGuestsByWebContentsId.get(webContentsId);
if (
attachedGuest?.contents === input.contents &&
attachedGuest.hostContents === input.hostContents
) {
return;
}
const guest: BrowserKeyboardGuest = input;
this.attachedGuestsByWebContentsId.set(webContentsId, guest);
@@ -33,6 +33,32 @@ describe("PaseoBrowserWebviewRegistry", () => {
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
});
it("keeps the active browser when the same guest registers again", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 1,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 1,
browserId: "browser-a",
hostWebContentsId: 101,
});
expect(registry.getActiveBrowserIdForWorkspaceInHostWindow(101, "workspace-a")).toBe(
"browser-a",
);
expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBe(1);
});
it("ignores stale destroy events after a duplicate browserId moved", () => {
const registry = new PaseoBrowserWebviewRegistry();
@@ -21,6 +21,14 @@ export class PaseoBrowserWebviewRegistry {
}): void {
const hostBrowserKey = this.hostBrowserKey(input.hostWebContentsId, input.browserId);
const replacedWebContentsId = this.webContentsIdsByHostAndBrowserId.get(hostBrowserKey);
const existingRegistration = this.registrationsByWebContentsId.get(input.webContentsId);
if (
replacedWebContentsId === input.webContentsId &&
existingRegistration?.browserId === input.browserId &&
existingRegistration.hostWebContentsId === input.hostWebContentsId
) {
return;
}
if (replacedWebContentsId !== undefined && replacedWebContentsId !== input.webContentsId) {
this.removeWebContents(replacedWebContentsId, { preserveActiveBrowser: true });
}