diff --git a/package.json b/package.json index 785c9f52..bc0d4091 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nexus-code", "productName": "NexusCode", - "version": "0.7.0", + "version": "0.7.1", "description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.", "license": "MIT", "private": true, diff --git a/src/renderer/state/operations/browser-suspend-auto.ts b/src/renderer/state/operations/browser-suspend-auto.ts index da999ca6..51144391 100644 --- a/src/renderer/state/operations/browser-suspend-auto.ts +++ b/src/renderer/state/operations/browser-suspend-auto.ts @@ -23,6 +23,24 @@ * [data-radix-popper-content-wrapper] — Popover, Tooltip popper containers * Watching these four covers every overlay primitive Radix ships. * + * FALSE POSITIVES — MONACO WIDGETS + * -------------------------------- + * The role-based half of the selector also matches Monaco's own editor + * widgets: the Find/Replace widget is `.editor-widget.find-widget[role="dialog"]`, + * the context menu is `[role="menu"]`, the suggest widget likewise. Monaco + * creates these lazily (first Cmd+F etc.) and then leaves them in the DOM + * PERMANENTLY in a hidden state (`aria-hidden="true"`) rather than unmounting. + * They live inside `.monaco-editor` and never occlude the browser + * WebContentsView — they are part of the editor pane, not a portal over it. + * + * Without filtering, the first Find in any editor leaves a permanent + * `[role="dialog"]` node behind, so `check()` reads `hasOverlay === true` + * forever, holds the suspend claim, and the browser tab in that workspace + * stays blank until a manual `resumeAll` or restart. `isOccludingOverlay()` + * excludes Monaco widgets (whole `.monaco-editor` subtree + the `editor-widget` + * class, in case overflow widgets are mounted outside it) and any + * `aria-hidden="true"` node, leaving only genuine on-screen portal overlays. + * * COALESCING * ---------- * MutationObserver can fire many times per render — the check is scheduled @@ -45,6 +63,31 @@ import { useBrowserSuspendStore } from "../stores/browser-suspend"; const OVERLAY_SELECTOR = '[role="dialog"],[role="alertdialog"],[role="menu"],[data-radix-popper-content-wrapper]'; +// Monaco editor widgets (find/replace, context menu, suggest) carry overlay +// roles but live inside the editor pane and persist in the DOM while hidden. +// Excluding the editor subtree (and the widget class, in case overflow widgets +// are mounted at the body) keeps them from registering as page overlays. +const MONACO_WIDGET_SELECTOR = ".monaco-editor,.editor-widget"; + +/** + * True when `el` is a portal overlay that actually occludes the browser view. + * + * Excludes (a) Monaco editor widgets — same overlay roles, but part of the + * editor pane, not a portal over the browser, and left in the DOM permanently + * once created; and (b) `aria-hidden="true"` nodes — a closed/inert overlay + * does not occlude anything (Radix marks BACKGROUND content aria-hidden, never + * the live overlay content, so this never drops a real overlay). + */ +export function isOccludingOverlay(el: Element): boolean { + if (el.matches(MONACO_WIDGET_SELECTOR) || el.closest(MONACO_WIDGET_SELECTOR) !== null) { + return false; + } + if (el.getAttribute("aria-hidden") === "true") { + return false; + } + return true; +} + let installed = false; /** @@ -69,7 +112,9 @@ export function initBrowserOverlayAutoSuspend(): void { function check(): void { pending = false; - const hasOverlay = document.querySelector(OVERLAY_SELECTOR) !== null; + const hasOverlay = Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some( + isOccludingOverlay, + ); if (hasOverlay && release === null) { // Entering overlay state — claim with snapshot capture so the modal // renders above a still frame of the page rather than a blank area. @@ -91,6 +136,15 @@ export function initBrowserOverlayAutoSuspend(): void { const observer = new MutationObserver(schedule); observer.observe(document.body, { childList: true, subtree: true }); + // Re-reconcile on focus / tab-visibility regain. A MutationObserver only + // fires on DOM changes, so a suspend state that desynced while the window + // was backgrounded (e.g. the OS dropped a `dragend`, or the display slept + // mid-overlay) would otherwise stay stuck until the next mutation. Coming + // back to the window forces a fresh check, restoring the live view when no + // overlay is actually present. + window.addEventListener("focus", schedule); + document.addEventListener("visibilitychange", schedule); + // Initial sync — covers the (unlikely but harmless) case where an overlay // is already present at install time, e.g. an HMR reload while a modal is // open in development. diff --git a/tests/unit/renderer/state/operations/browser-suspend-auto.test.ts b/tests/unit/renderer/state/operations/browser-suspend-auto.test.ts new file mode 100644 index 00000000..cc701695 --- /dev/null +++ b/tests/unit/renderer/state/operations/browser-suspend-auto.test.ts @@ -0,0 +1,68 @@ +/** + * Regression tests for `isOccludingOverlay` — the predicate that decides + * whether a DOM node matching the overlay-role selector actually occludes the + * browser WebContentsView (and therefore warrants suspending it). + * + * THE BUG THIS GUARDS + * ------------------- + * Monaco's Find/Replace widget is `.editor-widget.find-widget[role="dialog"]`. + * Monaco creates it lazily (first Cmd+F) and then leaves it in the DOM forever + * in a hidden state. The auto-suspend selector matches `[role="dialog"]`, so + * before the fix the first Find in any editor left a permanent "overlay" node + * behind → the browser stayed suspended (blank) until a manual resumeAll or a + * restart, per workspace. `isOccludingOverlay` must reject Monaco widgets and + * hidden nodes while still accepting genuine Radix portal overlays. + * + * No real DOM here — this project runs bun:test without jsdom/happy-dom, so we + * hand-build element stubs that implement only the three methods the predicate + * touches (`matches`, `closest`, `getAttribute`), matching the convention in + * keybindings/context-keys.test.ts. + */ + +import { describe, expect, test } from "bun:test"; + +import { isOccludingOverlay } from "../../../../../src/renderer/state/operations/browser-suspend-auto"; + +interface FakeSpec { + /** el.matches(MONACO_WIDGET_SELECTOR) — the node itself is a Monaco widget. */ + isMonacoWidget?: boolean; + /** el.closest(MONACO_WIDGET_SELECTOR) — the node sits inside the editor pane. */ + insideMonaco?: boolean; + /** el.getAttribute("aria-hidden") === "true". */ + ariaHidden?: boolean; +} + +// The predicate only ever passes the Monaco-widget selector to matches/closest, +// so the stub can resolve those calls from the spec booleans directly. +function fakeEl(spec: FakeSpec): Element { + return { + matches: () => Boolean(spec.isMonacoWidget), + closest: () => (spec.insideMonaco || spec.isMonacoWidget ? ({} as Element) : null), + getAttribute: (name: string) => + name === "aria-hidden" && spec.ariaHidden ? "true" : null, + } as unknown as Element; +} + +describe("isOccludingOverlay", () => { + test("rejects Monaco's closed Find/Replace widget (the regression)", () => { + // .editor-widget.find-widget[role=dialog][aria-hidden] inside .monaco-editor + expect(isOccludingOverlay(fakeEl({ insideMonaco: true, ariaHidden: true }))).toBe(false); + }); + + test("rejects an open Monaco widget (find visible, role=dialog, not hidden)", () => { + expect(isOccludingOverlay(fakeEl({ insideMonaco: true, ariaHidden: false }))).toBe(false); + }); + + test("rejects a Monaco overflow widget mounted outside .monaco-editor (matched by class)", () => { + // fixedOverflowWidgets mounts at the body — caught by the `.editor-widget` self-match. + expect(isOccludingOverlay(fakeEl({ isMonacoWidget: true, insideMonaco: false }))).toBe(false); + }); + + test("rejects any aria-hidden overlay node", () => { + expect(isOccludingOverlay(fakeEl({ ariaHidden: true }))).toBe(false); + }); + + test("accepts a genuine, visible Radix overlay (dialog / menu / popper)", () => { + expect(isOccludingOverlay(fakeEl({}))).toBe(true); + }); +});