diff --git a/apps/studio/src/client/atoms/studio-modal.ts b/apps/studio/src/client/atoms/studio-modal.ts index e47104620..a15c2b0eb 100644 --- a/apps/studio/src/client/atoms/studio-modal.ts +++ b/apps/studio/src/client/atoms/studio-modal.ts @@ -9,6 +9,14 @@ const openModalAtom = atom(null); +/** + * Whether any app-wide studio modal holds the slot. Read by hosts of a + * body-mounted browser guest, which is not inside the dialog's subtree and so + * would otherwise keep painting straight through its overlay (see + * use-guest-covered). + */ +export const isStudioModalOpenAtom = atom((get) => get(openModalAtom) !== null); + type StudioModalAtom = WritableAtom; /** diff --git a/apps/studio/src/client/components/task/browser-panel.tsx b/apps/studio/src/client/components/task/browser-panel.tsx index e168f37dc..8b2c4dd03 100644 --- a/apps/studio/src/client/components/task/browser-panel.tsx +++ b/apps/studio/src/client/components/task/browser-panel.tsx @@ -26,6 +26,7 @@ import { import { useIsActiveTab } from "@/client/hooks/use-active-tab"; import { useBrowserFind } from "@/client/hooks/use-browser-find"; import { useBrowserSlot } from "@/client/hooks/use-browser-slot"; +import { useIsGuestCovered } from "@/client/hooks/use-guest-covered"; import { getWebviewElement } from "@/client/lib/browser-pool"; import { EMULATED_DEVICES, @@ -104,17 +105,32 @@ export function TaskBrowserPanel({ // Set when a main-frame navigation fails (bad host, no network, ...). The // guest is parked and we show a light error state over the slot instead of its // blank error page. Cleared when a new load starts or succeeds. - const [loadError, setLoadError] = useState(null); + const loadError = failure?.targetId === targetId ? failure : null; // While the user is editing the URL, agent-driven navigations must not // overwrite what they're typing. const editingUrlRef = useRef(false); - const find = useBrowserFind({ active, isActiveTab, targetId }); + // Read once and give both hooks the same answer: a panel that parks its guest + // under an overlay must also stop being the Cmd+F target, or the overlay's + // own host claims the single find-opener slot, clears it on unmount, and this + // panel never re-registers. + const covered = useIsGuestCovered(); + const find = useBrowserFind({ active, covered, isActiveTab, targetId }); const slotRef = useBrowserSlot({ active, + covered, emulatedDeviceHeight: emulatedDevice?.height, emulatedDeviceWidth: emulatedDevice?.width, hasLoadError: Boolean(loadError), @@ -165,13 +181,21 @@ export function TaskBrowserPanel({ // Not attached yet; a did-navigate will re-run sync once it is. } }; + // Clear only a failure stamped with this listener's own guest. These + // listeners outlive `targetId` changing in place by the frame between the + // render and the effect teardown, and a bare clear arriving from the + // previous guest in that window would drop the current guest's error + // notice and unpark it over the slot. The other state these handlers write + // needs no such guard: the re-run's own `sync()` follows the teardown. + const clearFailure = () => { + setFailure((current) => + current?.targetId === targetId ? null : current, + ); + }; const onNavigate = () => { - setLoadError(null); + clearFailure(); sync(); }; - const onStartLoading = () => { - setLoadError(null); - }; const onFailLoad = (event: Event) => { const detail = event as DidFailLoadEvent; // Ignore sub-frame failures and user-aborted navigations (ERR_ABORTED), @@ -179,8 +203,9 @@ export function TaskBrowserPanel({ if (!detail.isMainFrame || detail.errorCode === -3) { return; } - setLoadError({ + setFailure({ message: detail.errorDescription || "This site can’t be reached", + targetId, url: detail.validatedURL, }); if (!editingUrlRef.current && detail.validatedURL) { @@ -198,12 +223,12 @@ export function TaskBrowserPanel({ sync(); webview.addEventListener("did-navigate", onNavigate); webview.addEventListener("did-navigate-in-page", onNavigate); - webview.addEventListener("did-start-loading", onStartLoading); + webview.addEventListener("did-start-loading", clearFailure); webview.addEventListener("did-fail-load", onFailLoad); return () => { webview.removeEventListener("did-navigate", onNavigate); webview.removeEventListener("did-navigate-in-page", onNavigate); - webview.removeEventListener("did-start-loading", onStartLoading); + webview.removeEventListener("did-start-loading", clearFailure); webview.removeEventListener("did-fail-load", onFailLoad); }; }, [active, targetId]); diff --git a/apps/studio/src/client/hooks/use-browser-find.ts b/apps/studio/src/client/hooks/use-browser-find.ts index a6989abc6..29a904dd3 100644 --- a/apps/studio/src/client/hooks/use-browser-find.ts +++ b/apps/studio/src/client/hooks/use-browser-find.ts @@ -17,10 +17,17 @@ interface FoundInPageEvent extends Event { */ export function useBrowserFind({ active, + covered = false, isActiveTab, targetId, }: { active: boolean; + // This host is behind a full-window overlay, so it is not the one Cmd+F + // should reach. The opener is a single slot: without this, a host that parks + // its guest keeps claiming it, and an overlay that takes the slot and clears + // it on unmount leaves the panel -- whose own inputs never changed -- never + // re-registering, so Cmd+F stops working entirely. + covered?: boolean; isActiveTab: boolean; targetId: BrowserTargetId; }) { @@ -71,7 +78,7 @@ export function useBrowserFind({ // the Cmd+F app command opens (and re-focuses) its find bar. See // browser-find-registry for why Cmd+F can't be a renderer keydown. useEffect(() => { - if (!active || !isActiveTab) { + if (!active || !isActiveTab || covered) { return; } return setBrowserFindOpener(() => { @@ -79,7 +86,7 @@ export function useBrowserFind({ findInputRef.current?.focus(); findInputRef.current?.select(); }); - }, [active, isActiveTab]); + }, [active, covered, isActiveTab]); // Focus the find input when the bar opens (its first render, when the opener // above couldn't focus it yet). Deferred a frame so it wins over Radix diff --git a/apps/studio/src/client/hooks/use-browser-slot.ts b/apps/studio/src/client/hooks/use-browser-slot.ts index c359020ae..0a964ba8d 100644 --- a/apps/studio/src/client/hooks/use-browser-slot.ts +++ b/apps/studio/src/client/hooks/use-browser-slot.ts @@ -18,6 +18,7 @@ interface DeviceEmulation { */ export function useBrowserSlot({ active, + covered = false, emulatedDeviceHeight, emulatedDeviceWidth, hasLoadError, @@ -25,6 +26,12 @@ export function useBrowserSlot({ targetId, }: { active: boolean; + // A full-window overlay is drawn over this slot. The guest is body-mounted, + // outside every dialog's subtree, so no overlay occludes it -- it keeps + // painting over the dim layer as though nothing opened. Opening a dialog is + // also not a tab switch, which is the only park signal this hook otherwise + // gets, so a covered host has to say so itself. + covered?: boolean; // Device size to emulate (see emulated-devices.ts presets), or // null/undefined for the panel's natural size. Passed as separate // primitives rather than an object so a new object identity per render @@ -64,7 +71,7 @@ export function useBrowserSlot({ // On a load error, park the guest so its blank error page doesn't cover our // own error state rendered in the slot. - if (!isActiveTab || hasLoadError) { + if (!isActiveTab || hasLoadError || covered) { setPaintHost(targetId, slotOwner); syncEmulation(null); return; @@ -139,6 +146,7 @@ export function useBrowserSlot({ }; }, [ active, + covered, isActiveTab, hasLoadError, slotOwner, diff --git a/apps/studio/src/client/hooks/use-guest-covered.ts b/apps/studio/src/client/hooks/use-guest-covered.ts new file mode 100644 index 000000000..9a6c068a0 --- /dev/null +++ b/apps/studio/src/client/hooks/use-guest-covered.ts @@ -0,0 +1,31 @@ +import { commandMenuOpenAtom } from "@/client/atoms/command-menu"; +import { filePreviewAtom } from "@/client/atoms/file-preview"; +import { isStudioModalOpenAtom } from "@/client/atoms/studio-modal"; +import { taskFileViewerAtom } from "@/client/atoms/task-file-viewer"; +import { useAtomValue } from "jotai"; + +/** + * Whether a full-window overlay is currently drawn over the page, for a host + * that shows a browser guest. + * + * The guest is mounted on `document.body`, outside every dialog's subtree, so no + * overlay occludes it the way it occludes ordinary content -- it keeps painting + * over the dim layer as though nothing opened. Opening a dialog is also not a + * tab switch, which is the only park signal `useBrowserSlot` otherwise receives, + * so a covered host has to say so itself. + * + * All four sources, because they are independent slots and a menu accelerator + * can open one over another: the app-wide studio modals (settings, sign-in, + * ...), the command palette (a dialog of the same shape, but on its own atom + * rather than the studio-modal slot), the file viewer's expand modal, and the + * chat's image/diagram preview. + */ +export function useIsGuestCovered(): boolean { + const studioModalOpen = useAtomValue(isStudioModalOpenAtom); + const commandMenuOpen = useAtomValue(commandMenuOpenAtom); + const fileViewerModalOpen = useAtomValue(taskFileViewerAtom).isModalOpen; + const filePreviewOpen = useAtomValue(filePreviewAtom).isOpen; + return ( + studioModalOpen || commandMenuOpen || fileViewerModalOpen || filePreviewOpen + ); +}