Skip to content

Commit 41d404b

Browse files
authored
Merge pull request #302 from LeoLin990405/fix/hidden-window-send
fix(browser): restore prompt submission when the Chrome window is hidden (#298)
2 parents 70dba4e + 05b8897 commit 41d404b

9 files changed

Lines changed: 164 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 0.15.3 — Unreleased
44

5+
### Fixed
6+
7+
- Browser: keep hidden macOS Chrome windows rendered off-screen so trusted prompt submissions land without retaining drafts or leaking them into later runs. Fixes #298 and #312. Thanks @LeoLin990405!
8+
59
## 0.15.2 — 2026-07-06
610

711
### Changed

src/browser/chromeLifecycle.ts

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@ import { rm } from "node:fs/promises";
22
import { readFileSync } from "node:fs";
33
import os from "node:os";
44
import net from "node:net";
5-
import { execFile } from "node:child_process";
6-
import { promisify } from "node:util";
75
import CDP from "chrome-remote-interface";
86
import { launch, Launcher, type LaunchedChrome } from "chrome-launcher";
97
import type { BrowserLogger, ResolvedBrowserConfig, ChromeClient } from "./types.js";
108
import { cleanupStaleProfileState } from "./profileState.js";
119
import { delay } from "./utils.js";
1210

13-
const execFileAsync = promisify(execFile);
14-
1511
export async function launchChrome(
1612
config: ResolvedBrowserConfig,
1713
userDataDir: string,
@@ -20,7 +16,11 @@ export async function launchChrome(
2016
const connectHost = resolveRemoteDebugHost();
2117
const debugBindAddress = connectHost && connectHost !== "127.0.0.1" ? "0.0.0.0" : connectHost;
2218
const debugPort = config.debugPort ?? parseDebugPortEnv();
23-
const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress);
19+
const chromeFlags = buildChromeFlags(
20+
config.headless ?? false,
21+
debugBindAddress,
22+
config.hideWindow ?? false,
23+
);
2424
const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
2525
// copy-profile reuses a copied signed-in profile whose cookies are
2626
// Keychain-encrypted, so it must launch with the real Keychain (not mocked):
@@ -56,6 +56,27 @@ export async function launchChrome(
5656
};
5757
}
5858

59+
export async function positionChromeWindowOffscreen(
60+
client: ChromeClient,
61+
logger: BrowserLogger,
62+
): Promise<void> {
63+
if (process.platform !== "darwin") {
64+
logger("Window hiding is only supported on macOS");
65+
return;
66+
}
67+
try {
68+
const { windowId } = await client.Browser.getWindowForTarget();
69+
await client.Browser.setWindowBounds({
70+
windowId,
71+
bounds: { left: -32_000, top: -32_000, windowState: "normal" },
72+
});
73+
logger("Chrome window positioned off-screen");
74+
} catch (error) {
75+
const message = error instanceof Error ? error.message : String(error);
76+
logger(`Failed to position Chrome window off-screen: ${message}`);
77+
}
78+
}
79+
5980
export function registerTerminationHooks(
6081
chrome: LaunchedChrome,
6182
userDataDir: string,
@@ -144,32 +165,6 @@ export function registerTerminationHooks(
144165
};
145166
}
146167

147-
export async function hideChromeWindow(
148-
chrome: LaunchedChrome,
149-
logger: BrowserLogger,
150-
): Promise<void> {
151-
if (process.platform !== "darwin") {
152-
logger("Window hiding is only supported on macOS");
153-
return;
154-
}
155-
if (!chrome.pid) {
156-
logger("Unable to hide window: missing Chrome PID");
157-
return;
158-
}
159-
const script = `tell application "System Events"
160-
try
161-
set visible of (first process whose unix id is ${chrome.pid}) to false
162-
end try
163-
end tell`;
164-
try {
165-
await execFileAsync("osascript", ["-e", script]);
166-
logger("Chrome window hidden (Cmd-H)");
167-
} catch (error) {
168-
const message = error instanceof Error ? error.message : String(error);
169-
logger(`Failed to hide Chrome window: ${message}`);
170-
}
171-
}
172-
173168
export async function connectToChrome(
174169
port: number,
175170
logger: BrowserLogger,
@@ -630,7 +625,11 @@ function isBlankPageTarget(target: { type?: string; url?: string }): boolean {
630625
return url === "about:blank" || url === "chrome://newtab/" || url === "chrome://new-tab-page/";
631626
}
632627

633-
function buildChromeFlags(headless: boolean, debugBindAddress?: string | null): string[] {
628+
function buildChromeFlags(
629+
headless: boolean,
630+
debugBindAddress?: string | null,
631+
hideWindow = false,
632+
): string[] {
634633
const flags = [
635634
"--disable-background-networking",
636635
"--disable-background-timer-throttling",
@@ -662,11 +661,24 @@ function buildChromeFlags(headless: boolean, debugBindAddress?: string | null):
662661

663662
if (headless) {
664663
flags.push("--headless=new");
664+
} else if (hideWindow && process.platform === "darwin") {
665+
// Cmd-H stops macOS Chrome from compositing the page, which can swallow
666+
// trusted CDP clicks and retain the prompt as a draft. Keeping the window
667+
// off-screen avoids desktop disruption while preserving normal rendering.
668+
flags.push("--window-position=-32000,-32000");
665669
}
666670

667671
return flags;
668672
}
669673

674+
export function buildChromeFlagsForTest(
675+
headless: boolean,
676+
debugBindAddress?: string | null,
677+
hideWindow = false,
678+
): string[] {
679+
return buildChromeFlags(headless, debugBindAddress, hideWindow);
680+
}
681+
670682
function resolveChromeLaunchOptions(
671683
chromeFlags: string[],
672684
usingCopiedProfile: boolean,

src/browser/controlPlan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,15 @@ export function describeBrowserControlPlan(config: BrowserControlConfig = {}): B
7474
}
7575

7676
if (config.hideWindow) {
77-
guidance.push("Chrome may briefly focus while launching before Oracle hides it.");
77+
guidance.push("On macOS, Oracle launches Chrome off-screen while keeping the page rendered.");
7878
guidance.push(
7979
"For the calmest shared-desktop flow, prefer --browser-attach-running or --remote-chrome.",
8080
);
8181
return {
8282
mode: "hidden-window",
8383
launchesChrome: true,
8484
mayFocusWindow: true,
85-
summary: "launch Chrome and hide the window after startup",
85+
summary: "launch Chrome in hidden-window mode",
8686
guidance,
8787
};
8888
}

src/browser/index.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
import {
1717
launchChrome,
1818
registerTerminationHooks,
19-
hideChromeWindow,
19+
positionChromeWindowOffscreen,
2020
connectToRemoteChrome,
2121
connectWithNewTab,
2222
closeTab,
@@ -448,6 +448,29 @@ async function createAssistantTimeoutError(params: {
448448
return warningError;
449449
}
450450

451+
/**
452+
* Make the page behave like a focused foreground tab.
453+
*
454+
* The send button is activated with trusted CDP input events dispatched at
455+
* viewport coordinates. Chrome delivers those only to a window that is being
456+
* composited, so a hidden (`--browser-hide-window`), minimized, or occluded
457+
* window swallows the click while the automation still believes it clicked.
458+
* Soft-fails: focus emulation is an optimization, never a hard requirement.
459+
*/
460+
async function enableFocusEmulation(
461+
client: ChromeClient,
462+
logger: BrowserLogger,
463+
label: string,
464+
): Promise<void> {
465+
try {
466+
await client.Emulation.setFocusEmulationEnabled({ enabled: true });
467+
logger(`[browser] Focus emulation enabled for ${label}`);
468+
} catch (error) {
469+
const message = error instanceof Error ? error.message : String(error);
470+
logger(`[browser] Focus emulation unavailable: ${message}`);
471+
}
472+
}
473+
451474
function listIgnoredRemoteChromeFlags(config: {
452475
attachRunning?: ResolvedBrowserConfig["attachRunning"];
453476
headless?: ResolvedBrowserConfig["headless"];
@@ -1144,15 +1167,18 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise<Browse
11441167
Promise.race([promise, disconnectPromise]);
11451168
const { Network, Page, Runtime, Input, DOM, Target } = client;
11461169

1147-
if (!config.headless && config.hideWindow) {
1148-
await hideChromeWindow(chrome, logger);
1149-
}
1150-
11511170
const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
11521171
if (DOM && typeof DOM.enable === "function") {
11531172
domainEnablers.push(DOM.enable());
11541173
}
11551174
await Promise.all(domainEnablers);
1175+
if (!config.headless && config.hideWindow) {
1176+
await positionChromeWindowOffscreen(client, logger);
1177+
}
1178+
// The send button is clicked with trusted CDP input events at viewport
1179+
// coordinates, which ChatGPT silently drops when the window is hidden or
1180+
// occluded. Emulate focus so the page behaves like a foreground tab.
1181+
await enableFocusEmulation(client, logger, "local target");
11561182
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
11571183
if (!profileIsPreSigned) {
11581184
await Network.clearBrowserCookies();
@@ -2836,13 +2862,7 @@ async function runRemoteBrowserMode(
28362862
}
28372863
await Promise.all(domainEnablers);
28382864
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
2839-
try {
2840-
await client.Emulation.setFocusEmulationEnabled({ enabled: true });
2841-
logger("[browser] Focus emulation enabled for remote target");
2842-
} catch (error) {
2843-
const message = error instanceof Error ? error.message : String(error);
2844-
logger(`[browser] Focus emulation unavailable: ${message}`);
2845-
}
2865+
await enableFocusEmulation(client, logger, "remote target");
28462866

28472867
const activeConversationUrlMonitor = createConversationUrlMonitor({
28482868
readUrl: async () => {

src/browser/projectSourcesRunner.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { LaunchedChrome } from "chrome-launcher";
55
import {
66
closeTab,
77
connectWithNewTab,
8-
hideChromeWindow,
98
launchChrome,
9+
positionChromeWindowOffscreen,
1010
registerTerminationHooks,
1111
} from "./chromeLifecycle.js";
1212
import { resolveBrowserConfig } from "./config.js";
@@ -181,14 +181,14 @@ export async function runBrowserProjectSources(
181181
Promise.race([promise, disconnectPromise]);
182182

183183
const { Network, Page, Runtime, Input, DOM, Target } = client;
184-
if (!config.headless && config.hideWindow) {
185-
await hideChromeWindow(chrome, logger);
186-
}
187184
const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
188185
if (DOM && typeof DOM.enable === "function") {
189186
domainEnablers.push(DOM.enable());
190187
}
191188
await Promise.all(domainEnablers);
189+
if (!config.headless && config.hideWindow) {
190+
await positionChromeWindowOffscreen(client, logger);
191+
}
192192
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
193193
if (!manualLogin) {
194194
await Network.clearBrowserCookies();

src/browser/reattach.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { BrowserLogger, ChromeClient } from "./types.js";
1515
import {
1616
launchChrome,
1717
connectToChrome,
18-
hideChromeWindow,
18+
positionChromeWindowOffscreen,
1919
connectToRemoteChromeTarget,
2020
listRemoteChromeTargets,
2121
} from "./chromeLifecycle.js";
@@ -290,9 +290,8 @@ async function resumeBrowserSessionViaNewChrome(
290290
await DOM.enable();
291291
}
292292
if (!resolved.headless && resolved.hideWindow) {
293-
await hideChromeWindow(chrome, logger);
293+
await positionChromeWindowOffscreen(client, logger);
294294
}
295-
296295
let appliedCookies = 0;
297296
if (!manualLogin && resolved.cookieSync) {
298297
appliedCookies = await syncCookies(Network, resolved.url, resolved.chromeProfile, logger, {

tests/browser/chromeLifecycle.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,47 @@ describe("copied-profile launch flags", () => {
123123
});
124124
});
125125

126+
describe("hidden-window launch flags", () => {
127+
test("keeps macOS Chrome rendered in an off-screen window", async () => {
128+
const { buildChromeFlagsForTest } = await import("../../src/browser/chromeLifecycle.js");
129+
const flags = buildChromeFlagsForTest(false, undefined, true);
130+
131+
if (process.platform === "darwin") {
132+
expect(flags).toContain("--window-position=-32000,-32000");
133+
} else {
134+
expect(flags).not.toContain("--window-position=-32000,-32000");
135+
}
136+
});
137+
138+
test("does not add a window position to headless Chrome", async () => {
139+
const { buildChromeFlagsForTest } = await import("../../src/browser/chromeLifecycle.js");
140+
141+
expect(buildChromeFlagsForTest(true, undefined, true)).not.toContain(
142+
"--window-position=-32000,-32000",
143+
);
144+
});
145+
146+
test("moves a running macOS Chrome window without minimizing it", async () => {
147+
const { positionChromeWindowOffscreen } = await import("../../src/browser/chromeLifecycle.js");
148+
const browser = {
149+
getWindowForTarget: vi.fn().mockResolvedValue({ windowId: 7 }),
150+
setWindowBounds: vi.fn().mockResolvedValue(undefined),
151+
};
152+
const logger = vi.fn();
153+
154+
await positionChromeWindowOffscreen({ Browser: browser } as never, logger as never);
155+
156+
if (process.platform === "darwin") {
157+
expect(browser.setWindowBounds).toHaveBeenCalledWith({
158+
windowId: 7,
159+
bounds: { left: -32_000, top: -32_000, windowState: "normal" },
160+
});
161+
} else {
162+
expect(browser.setWindowBounds).not.toHaveBeenCalled();
163+
}
164+
});
165+
});
166+
126167
describe("connectWithNewTab", () => {
127168
beforeEach(() => {
128169
cdpMock.mockReset();

tests/browser/controlPlan.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ describe("browser control plan", () => {
3333
});
3434

3535
test("describes hidden and remote modes distinctly", () => {
36-
expect(describeBrowserControlPlan({ hideWindow: true }).mode).toBe("hidden-window");
36+
const hidden = describeBrowserControlPlan({ hideWindow: true });
37+
expect(hidden.mode).toBe("hidden-window");
38+
expect(hidden.guidance.join(" ")).toContain("off-screen");
3739
expect(
3840
describeBrowserControlPlan({ remoteChrome: { host: "127.0.0.1", port: 9222 } }).mode,
3941
).toBe("remote-chrome");

tests/browser/promptComposer.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("promptComposer", () => {
2525
);
2626
});
2727

28-
test("does not treat cleared composer + stop button as committed without a new turn", async () => {
28+
test("does not treat historical assistant content as committed without a new turn", async () => {
2929
vi.useFakeTimers();
3030
try {
3131
const runtime = {
@@ -44,7 +44,7 @@ describe("promptComposer", () => {
4444
lastMatched: false,
4545
hasNewTurn: false,
4646
stopVisible: true,
47-
assistantVisible: false,
47+
assistantVisible: true,
4848
composerCleared: true,
4949
inConversation: false,
5050
},
@@ -294,4 +294,34 @@ describe("promptComposer", () => {
294294

295295
expect(onPromptSubmitted).toHaveBeenCalledTimes(1);
296296
});
297+
298+
test("waits for a delayed trusted click without issuing a second send", async () => {
299+
vi.useFakeTimers();
300+
try {
301+
const evaluate = vi.fn().mockResolvedValue({
302+
result: { value: { status: "point", x: 10, y: 20 } },
303+
});
304+
const input = {
305+
dispatchMouseEvent: vi.fn(async ({ type }: { type: string }) => {
306+
if (type === "mouseReleased") {
307+
await new Promise((resolve) => setTimeout(resolve, 1_000));
308+
}
309+
}),
310+
};
311+
312+
const result = promptComposer.attemptSendButton(
313+
{ evaluate } as never,
314+
input as never,
315+
undefined,
316+
undefined,
317+
);
318+
await vi.advanceTimersByTimeAsync(1_000);
319+
320+
await expect(result).resolves.toBe(true);
321+
expect(evaluate).toHaveBeenCalledTimes(1);
322+
expect(input.dispatchMouseEvent).toHaveBeenCalledTimes(3);
323+
} finally {
324+
vi.useRealTimers();
325+
}
326+
});
297327
});

0 commit comments

Comments
 (0)