diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx
index 4a241befef..d27b5ec429 100644
--- a/apps/web/src/browser/HostedBrowserWebview.test.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx
@@ -48,6 +48,7 @@ import {
} from "~/hooks/useSettings";
import { useBrowserSurfaceStore } from "./browserSurfaceStore";
import * as desktopTabLifetime from "./desktopTabLifetime";
+import { isGuestFocusPointerDown } from "./guestFocusPointer";
import { HostedBrowserWebview } from "./HostedBrowserWebview";
let renderer: ReactTestRenderer | undefined;
@@ -99,6 +100,74 @@ afterEach(async () => {
});
describe("HostedBrowserWebview settings hydration", () => {
+ it("dismisses host popups on guest focus and removes the listener on unmount", async () => {
+ mocks.getClientSettings.mockResolvedValue(DEFAULT_CLIENT_SETTINGS);
+ await act(async () => ensureClientSettingsHydrated());
+ class TestPointerEvent extends Event {
+ readonly pointerType: string;
+
+ constructor(type: string, init: EventInit & { pointerType: string }) {
+ super(type, init);
+ this.pointerType = init.pointerType;
+ }
+ }
+ vi.stubGlobal("PointerEvent", TestPointerEvent);
+ const guest = Object.assign(new EventTarget(), { getWebContentsId: () => 41 });
+ const hostDocument = new EventTarget();
+ const onHostOutsidePointer = vi.fn();
+ hostDocument.addEventListener("pointerdown", onHostOutsidePointer);
+ const dispatchGuestEvent = guest.dispatchEvent.bind(guest);
+ vi.spyOn(guest, "dispatchEvent").mockImplementation((event) => {
+ const delivered = dispatchGuestEvent(event);
+ if (event.bubbles) hostDocument.dispatchEvent(event);
+ return delivered;
+ });
+ const onPointerDown = vi.fn();
+ guest.addEventListener("pointerdown", onPointerDown);
+
+ await act(() => {
+ renderer = create(
+ ,
+ {
+ createNodeMock: (element) =>
+ element.type === "webview"
+ ? guest
+ : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined },
+ },
+ );
+ });
+
+ guest.dispatchEvent(new Event("focus"));
+ expect(onPointerDown).toHaveBeenCalledOnce();
+ expect(onHostOutsidePointer).toHaveBeenCalledOnce();
+ expect(isGuestFocusPointerDown(onHostOutsidePointer.mock.calls[0]?.[0])).toBe(true);
+ expect(
+ isGuestFocusPointerDown(new TestPointerEvent("pointerdown", { pointerType: "mouse" })),
+ ).toBe(false);
+ expect(onPointerDown.mock.calls[0]?.[0]).toMatchObject({
+ type: "pointerdown",
+ bubbles: true,
+ pointerType: "mouse",
+ });
+
+ await act(() => renderer?.unmount());
+ guest.dispatchEvent(new Event("focus"));
+ expect(onPointerDown).toHaveBeenCalledOnce();
+ expect(onHostOutsidePointer).toHaveBeenCalledOnce();
+ });
+
it("starts a retained background tab only after a settings read succeeds on retry", async () => {
const firstRead = deferred();
const retryRead = deferred();
diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx
index 521da09397..e487d46b40 100644
--- a/apps/web/src/browser/HostedBrowserWebview.tsx
+++ b/apps/web/src/browser/HostedBrowserWebview.tsx
@@ -23,6 +23,7 @@ import {
import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
+import { dispatchGuestFocusPointerDown } from "./guestFocusPointer";
import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
import { useBrowserViewportResize } from "./useBrowserViewportResize";
@@ -180,7 +181,13 @@ export function HostedBrowserWebview(props: {
dispatchSnapShotComposerFocus();
}
};
+ // Guest presses do not reach this document, so host popovers miss their
+ // outside-pointer dismissal when focus moves into the webview.
+ const dismissHostPopups = () => {
+ dispatchGuestFocusPointerDown(webview);
+ };
webview.addEventListener("focus", handleFocus);
+ webview.addEventListener("focus", dismissHostPopups);
webview.addEventListener("did-attach", register);
webview.addEventListener("dom-ready", register);
webview.addEventListener("render-process-gone", recoverGuest);
@@ -189,6 +196,7 @@ export function HostedBrowserWebview(props: {
disposed = true;
if (recoveryTimeout !== null) clearTimeout(recoveryTimeout);
webview.removeEventListener("focus", handleFocus);
+ webview.removeEventListener("focus", dismissHostPopups);
webview.removeEventListener("did-attach", register);
webview.removeEventListener("dom-ready", register);
webview.removeEventListener("render-process-gone", recoverGuest);
diff --git a/apps/web/src/browser/guestFocusPointer.ts b/apps/web/src/browser/guestFocusPointer.ts
new file mode 100644
index 0000000000..77ddeef05b
--- /dev/null
+++ b/apps/web/src/browser/guestFocusPointer.ts
@@ -0,0 +1,13 @@
+// A guest press only surfaces in the host as webview focus. Mark the synthetic
+// outside pointer so the theme inspector leaves it to host popup listeners.
+const guestFocusPointers = new WeakSet();
+
+export function dispatchGuestFocusPointerDown(webview: HTMLElement): void {
+ const event = new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" });
+ guestFocusPointers.add(event);
+ webview.dispatchEvent(event);
+}
+
+export function isGuestFocusPointerDown(event: Event): boolean {
+ return guestFocusPointers.has(event);
+}
diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx
index 8f9dccd249..53503c0ffd 100644
--- a/apps/web/src/components/settings/ThemeEditorPanel.tsx
+++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx
@@ -35,6 +35,7 @@ import {
type ThemeDefinition,
} from "../../themePalette";
import { cn } from "../../lib/utils";
+import { isGuestFocusPointerDown } from "../../browser/guestFocusPointer";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
@@ -682,6 +683,7 @@ export function ThemeEditorPanel({
if (event.relatedTarget === null) clearHover();
};
const handlePointerDown = (event: PointerEvent) => {
+ if (isGuestFocusPointerDown(event)) return;
const target = event.target;
if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return;
event.preventDefault();