Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b76cd78
fix(desktop): agents can drive the popup windows their desktop tabs open
shivamhwp Nov 3, 2026
0ae0d33
fix(desktop): retain popups rejected by tab limits
shivamhwp Nov 3, 2026
f34f0a9
fix(desktop): preserve native popups when setup fails
shivamhwp Nov 3, 2026
6a4ba28
fix(desktop): detach popup child debugger sessions
shivamhwp Nov 4, 2026
65b0b59
fix(desktop): detach announced popup relay sessions
shivamhwp Nov 4, 2026
b73700e
fix(desktop): release detached popup capture holds
shivamhwp Nov 4, 2026
d9272c7
fix(desktop): notify clients when popup sessions detach
shivamhwp Nov 4, 2026
57eeb3f
fix(desktop): revoke detached popup child routes
shivamhwp Nov 4, 2026
9f6d46b
fix(server): keep desktop popups across debugger recovery and their d…
shivamhwp Nov 4, 2026
65b0e26
Merge remote-tracking branch 'github/main' into issue-watch/30384
maria-rcks Nov 5, 2026
2888cbe
fix(server): idle reclamation keeps desktop popup windows and their o…
shivamhwp Nov 5, 2026
a737bcc
fix(server): retain popup opener protection after failed adoption
shivamhwp Nov 5, 2026
fbfdcf9
Merge origin/main 67eebac into issue-watch/30384
shivamhwp Nov 5, 2026
cf696c1
fix(desktop): recover native popup access after DevTools closes
shivamhwp Nov 5, 2026
4efd43a
fix(desktop): retain popup opener protection through debugger detachment
shivamhwp Nov 5, 2026
25cb5f4
fix(server): keep tabs with active agent actions out of idle reclamation
shivamhwp Nov 5, 2026
3c9fed1
fix(server): retain native popup dialogs through adoption
shivamhwp Nov 5, 2026
e334056
fix(desktop): retain native dialog responses before popup adoption
shivamhwp Nov 5, 2026
ebe6475
fix(server): answer native popup dialogs that hold adoption setup
shivamhwp Nov 5, 2026
2ce507e
test(server): held popup setup still denies other agents and human co…
shivamhwp Nov 5, 2026
436d95b
Merge commit '337c5858c33bc7f8f6dfd391a2c279fa0f7e4d17' into issue-wa…
shivamhwp Nov 5, 2026
6ec4c56
fix(server): client closes no longer wait for a dialog holding popup …
shivamhwp Nov 5, 2026
3894cc8
fix(server): viewers connect while a dialog holds the page's first frame
shivamhwp Nov 5, 2026
662bec1
Merge commit 'cec8d73a1f0fd3bd9a48df88640436e73a04afa3' into issue-wa…
shivamhwp Nov 10, 2026
9028a57
fix(desktop): closed popups no longer block download setup
shivamhwp Nov 10, 2026
5e7ec52
Merge commit '4c924ff4e6d0828e0a5810336ec7d7827d752645' into issue-wa…
shivamhwp Nov 10, 2026
b04444d
Merge remote-tracking branch 'origin/main' into t3/test-native-previe…
shivamhwp Nov 10, 2026
560ab13
Merge remote-tracking branch 'origin/main' into issue-watch/30384
juliusmarminge Nov 11, 2026
dddbcbc
fix(server): a native popup opening as its opener closes stays open
juliusmarminge Nov 11, 2026
ecec713
fix(server): a native popup's dialog is answered in its own window
juliusmarminge Nov 11, 2026
6aada0a
Merge remote-tracking branch 'origin/main' into issue-watch/30384
juliusmarminge Nov 11, 2026
8125d8c
fix(server): a dialog the person answers natively clears from its pop…
juliusmarminge Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
456 changes: 443 additions & 13 deletions apps/desktop/src/preview/CdpRelay.test.ts

Large diffs are not rendered by default.

319 changes: 259 additions & 60 deletions apps/desktop/src/preview/CdpRelay.ts

Large diffs are not rendered by default.

323 changes: 308 additions & 15 deletions apps/desktop/src/preview/DesktopBrowserHost.test.ts

Large diffs are not rendered by default.

539 changes: 359 additions & 180 deletions apps/desktop/src/preview/DesktopBrowserHost.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ describe("PreviewManager", () => {
Layer.mock(DesktopBrowserHost.DesktopBrowserHost)({
attach: () => {},
detach: () => {},
openedPopup: () => {},
setShown: () => {},
humanStartedDownload: () => true,
placeDownload: () => true,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
window.webContents.on("before-input-event", (_event, input) => {
syncMenuShortcuts(window.webContents, input);
});
browserHost.openedPopup(wc, window.webContents);
};
const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
syncMenuShortcuts(wc, input);
Expand Down
107 changes: 87 additions & 20 deletions apps/server/src/preview/DesktopBrowserChannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeStream from "@effect/platform-node/NodeStream";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Queue from "effect/Queue";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import { vi } from "vite-plus/test";

import * as ServerConfig from "../config.ts";
import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts";

vi.mock("@effect/platform-node/NodeStream", () => ({ fromReadable: vi.fn() }));
Expand All @@ -16,52 +20,115 @@ vi.mock("node:net", () => ({
destroy() {}
},
}));
vi.mock("../resourceTelemetry/DesktopTelemetryReceiver.ts", () => ({
writeAllToFileDescriptor: vi.fn(),
}));

it.effect("protects unknown desktop visibility until an explicit hidden report arrives", () =>
const withChannel = <A, E>(
body: (
channel: DesktopBrowserChannel.DesktopBrowserChannel["Service"],
send: (event: object) => Effect.Effect<boolean>,
) => Effect.Effect<A, E, Scope.Scope>,
) =>
Effect.gen(function* () {
const input = yield* Queue.unbounded<Uint8Array>();
vi.mocked(NodeStream.fromReadable).mockReturnValue(Stream.fromQueue(input));
const config = yield* ServerConfig.ServerConfig;
yield* Effect.gen(function* () {
const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel;
const send = (event: object) =>
Queue.offer(input, new TextEncoder().encode(`${JSON.stringify(event)}\n`));
return yield* Effect.gen(function* () {
return yield* body(yield* DesktopBrowserChannel.DesktopBrowserChannel, send);
}).pipe(
Effect.provide(
DesktopBrowserChannel.layer.pipe(
Layer.provide(
ServerConfig.layer({ ...config, desktopBrowserFd: 41, desktopBrowserControlFd: 42 }),
),
),
),
);
}).pipe(
Effect.scoped,
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "desktop-browser-channel-" }).pipe(
Layer.provide(NodeServices.layer),
),
),
);

it.effect("protects unknown desktop visibility until an explicit hidden report arrives", () =>
withChannel((channel, send) =>
Effect.gen(function* () {
vi.mocked(writeAllToFileDescriptor).mockReturnValue(Effect.void);
const key = { threadId: "thread-1", tabId: "tab-1" };
const send = (event: object) =>
Queue.offer(input, new TextEncoder().encode(`${JSON.stringify(event)}\n`));

expect(channel.isShown(key)).toBe(true);
yield* send({ type: "attached", ...key });
yield* channel.awaitAttached(key, "5 seconds");
expect(channel.isShown(key)).toBe(true);
expect(channel.hasNativePopups(key)).toBe(false);

yield* send({ type: "shown", ...key, shown: false });
yield* send({ type: "popupWindows", ...key, open: true });
yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-1" });
yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-1" }, "5 seconds");
expect(channel.isShown(key)).toBe(false);
expect(channel.hasNativePopups(key)).toBe(true);

yield* send({ type: "shown", ...key, shown: true });
yield* send({ type: "popupWindows", ...key, open: false });
yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-2" });
yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-2" }, "5 seconds");
expect(channel.isShown(key)).toBe(true);
expect(channel.hasNativePopups(key)).toBe(false);

yield* send({ type: "shown", ...key, shown: false });
yield* send({ type: "popupWindows", ...key, open: true });
yield* send({ type: "detached", ...key });
yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-3" });
yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-3" }, "5 seconds");
expect(channel.isShown(key)).toBe(true);
}).pipe(
Effect.provide(
DesktopBrowserChannel.layer.pipe(
Layer.provide(
ServerConfig.layer({ ...config, desktopBrowserFd: 41, desktopBrowserControlFd: 42 }),
),
),
),
);
}).pipe(
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "desktop-browser-channel-" }).pipe(
Layer.provide(NodeServices.layer),
),
),
expect(channel.hasNativePopups(key)).toBe(false);
}),
),
);

it.live("a replaced endpoint's shutdown does not release the endpoint that replaced it", () =>
withChannel((channel, send) =>
Effect.gen(function* () {
const key = { threadId: "thread-1", tabId: "tab-1" };
const commands = yield* Queue.unbounded<{ readonly type: string }>();
vi.mocked(writeAllToFileDescriptor).mockImplementation((_fd, buffer) =>
Queue.offer(commands, JSON.parse(buffer.toString())).pipe(Effect.asVoid),
);

yield* send({ type: "attached", ...key });
expect(yield* channel.awaitAttached(key, "5 seconds")).toBe(true);
const first = yield* Scope.make();
yield* channel.endpoint(key, { popups: true }).pipe(Scope.provide(first));
expect((yield* Queue.take(commands)).type).toBe("popups");

// The desktop recovers a dropped debugger: the tab detaches and attaches again, and the
// reconnect opens its endpoint before the dropped connection finishes closing.
const detached = yield* channel.detached.pipe(
Stream.runHead,
Effect.forkChild({ startImmediately: true }),
);
yield* send({ type: "detached", ...key });
yield* Fiber.join(detached);
yield* send({ type: "attached", ...key });
expect(yield* channel.awaitAttached(key, "5 seconds")).toBe(true);
const second = yield* Scope.make();
yield* channel.endpoint(key, { popups: true }).pipe(Scope.provide(second));
expect((yield* Queue.take(commands)).type).toBe("popups");

yield* Scope.close(first, Exit.void);
yield* Scope.close(second, Exit.void);
// Commands are written in order, so everything the two closes sent precedes this one.
yield* channel.turn(key.threadId, false);
const sent: Array<string> = [];
while (sent.at(-1) !== "turn") sent.push((yield* Queue.take(commands)).type);
expect(sent).toEqual(["release", "turn"]);
}),
),
);
52 changes: 39 additions & 13 deletions apps/server/src/preview/DesktopBrowserChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export class DesktopBrowserChannel extends Context.Service<
readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
/** Whether a desktop window draws the tab on screen, which no streamed viewer counts. */
readonly isShown: (key: DesktopTabKey) => boolean;
readonly endpoint: (key: DesktopTabKey) => Effect.Effect<string, never, Scope.Scope>;
readonly hasNativePopups: (key: DesktopTabKey) => boolean;
/** With `popups`, the tab's popup windows become pages of the same connection. */
readonly endpoint: (
key: DesktopTabKey,
options?: { readonly popups: boolean },
) => Effect.Effect<string, never, Scope.Scope>;
readonly pointer: (
key: DesktopTabKey,
pointer: { readonly phase: "move" | "click"; readonly x: number; readonly y: number },
Expand All @@ -64,6 +69,7 @@ const make = Effect.gen(function* () {
const attachedTabs = new Set<string>();
// Missing reports protect desktop tabs until the renderer explicitly marks them hidden.
const shownTabs = new Map<string, boolean>();
const popupOpeners = new Set<string>();
const inbound = new Map<string, Queue.Queue<string>>();
const writeLock = yield* Semaphore.make(1);

Expand All @@ -88,18 +94,16 @@ const make = Effect.gen(function* () {
return Effect.void;
}),
isShown: () => false,
hasNativePopups: () => false,
});
}

const command = (message: DesktopBrowserCommandType) =>
writeLock.withPermits(1)(
encodeCommand(message).pipe(
Effect.flatMap((line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`))),
Effect.catchCause((cause) =>
Effect.logWarning("desktop browser command failed", { cause }),
),
),
const write = (message: DesktopBrowserCommandType) =>
encodeCommand(message).pipe(
Effect.flatMap((line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`))),
Effect.catchCause((cause) => Effect.logWarning("desktop browser command failed", { cause })),
);
const command = (message: DesktopBrowserCommandType) => writeLock.withPermits(1)(write(message));

// Use cancellable socket reads; filesystem pipe reads keep Node alive after scope shutdown.
const readable = yield* Effect.acquireRelease(
Expand Down Expand Up @@ -129,6 +133,7 @@ const make = Effect.gen(function* () {
case "detached": {
attachedTabs.delete(id);
shownTabs.delete(id);
popupOpeners.delete(id);
const queue = inbound.get(id);
return (queue ? Queue.shutdown(queue) : Effect.void).pipe(
Effect.andThen(PubSub.publish(changes, { key, attached: false })),
Expand All @@ -137,14 +142,26 @@ const make = Effect.gen(function* () {
case "shown":
shownTabs.set(id, event.shown);
return Effect.void;
case "popupWindows":
if (event.open) popupOpeners.add(id);
else popupOpeners.delete(id);
return Effect.void;
}
}),
Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })),
Effect.ensuring(Effect.sync(() => shownTabs.clear())),
Effect.ensuring(
Effect.sync(() => {
shownTabs.clear();
popupOpeners.clear();
}),
),
Effect.forkScoped,
);

const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (key: DesktopTabKey) {
const endpoint = Effect.fn("DesktopBrowserChannel.endpoint")(function* (
key: DesktopTabKey,
options?: { readonly popups: boolean },
) {
const id = keyOf(key);
const secret = NodeCrypto.randomBytes(24).toString("base64url");
const server = yield* NodeSocketServer.makeWebSocket({
Expand All @@ -161,11 +178,19 @@ const make = Effect.gen(function* () {
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
if (inbound.get(id) === queue) inbound.delete(id);
yield* Queue.shutdown(queue);
yield* command({ type: "release", ...key });
// A reconnect's endpoint may already hold the tab; its release would drop the new relay.
yield* writeLock.withPermits(1)(
Effect.suspend(() => {
if (inbound.get(id) !== queue) return Effect.void;
inbound.delete(id);
return write({ type: "release", ...key });
}),
);
}),
);
// Written before any CDP, so popups already open are there when Playwright connects.
if (options?.popups) yield* command({ type: "popups", ...key });
// The relay serves one Playwright connection; a second would see the first's sessions.
let connected = false;
yield* server
Expand Down Expand Up @@ -232,6 +257,7 @@ const make = Effect.gen(function* () {
return Effect.sync(() => attachedTabs.has(keyOf(key)));
}),
isShown: (key) => shownTabs.get(keyOf(key)) ?? true,
hasNativePopups: (key) => popupOpeners.has(keyOf(key)),
endpoint,
turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) {
return command({ type: "turn", threadId, active });
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ export class PreviewManager extends Context.Service<
readonly open: (
input: PreviewOpenInput & {
readonly automationOwner?: string;
/** The desktop streams the tab from its first snapshot instead of hosting a webview. */
readonly headless?: boolean;
/** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */
readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void;
},
) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
readonly reportStatus: (
input: PreviewReportStatusInput & { readonly serverControlled?: boolean },
) => Effect.Effect<void, PreviewError>;
/** Records that a server tab runs headless. It stays headless until it closes. */
/** Records that the desktop streams a server tab. It stays streamed until it closes. */
readonly markHeadless: (
input: PreviewCloseInput & { readonly tabId: string },
) => Effect.Effect<void, PreviewError>;
Expand Down Expand Up @@ -197,6 +199,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
? { automationOwner: input.automationOwner }
: {}),
...(input.reveal === undefined ? {} : { reveal: input.reveal }),
...(runtime === "server" && input.headless ? { headless: true } : {}),
updatedAt,
};
yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
Expand Down
Loading
Loading