From ff799eabcb9cfdb238402b89005cf073fed20975 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Tue, 4 Aug 2026 17:05:46 -0500 Subject: [PATCH 1/3] studio: stop the browser guest outliving the overlays drawn over it Three faults in the task browser panel, all pre-existing and all from the guest being a `` on `document.body` rather than an element in the page. Nothing occludes it. An app-wide dialog -- settings, sign-in, the shortcut guide, the file viewer's expand modal, the chat's image preview -- draws its dim layer over the page and the guest keeps painting straight over the top of it. Opening a dialog is also not a tab switch, which is the only park signal the slot otherwise gets, so the panel now reports coverage itself. Cmd+F stops working after any of those closes. The find opener is a single slot; an overlay's own host claims it, clears it on unmount, and the panel -- whose inputs never changed -- never re-registers. Both hooks now read one coverage value, so the panel gives up the slot and takes it back. A load failure survives a session switch. `targetId` changes in place when the selected session changes, with no remount, so the next session's guest is parked behind an error notice naming the previous session's URL. The failure is stamped with the guest it happened on and filtered on read, which costs no extra render and never briefly shows a failed page as fine. --- apps/studio/src/client/atoms/studio-modal.ts | 8 ++++++ .../client/components/task/browser-panel.tsx | 27 +++++++++++++++---- .../src/client/hooks/use-browser-find.ts | 11 ++++++-- .../src/client/hooks/use-browser-slot.ts | 10 ++++++- .../src/client/hooks/use-guest-covered.ts | 25 +++++++++++++++++ 5 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 apps/studio/src/client/hooks/use-guest-covered.ts 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..ad7aec4c4 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), @@ -166,11 +182,11 @@ export function TaskBrowserPanel({ } }; const onNavigate = () => { - setLoadError(null); + setFailure(null); sync(); }; const onStartLoading = () => { - setLoadError(null); + setFailure(null); }; const onFailLoad = (event: Event) => { const detail = event as DidFailLoadEvent; @@ -179,8 +195,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) { 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..17c784cd5 --- /dev/null +++ b/apps/studio/src/client/hooks/use-guest-covered.ts @@ -0,0 +1,25 @@ +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 three sources, because they are independent slots and a menu accelerator + * can open one over another: the app-wide studio modals (settings, sign-in, + * ...), the file viewer's expand modal, and the chat's image/diagram preview. + */ +export function useIsGuestCovered(): boolean { + const studioModalOpen = useAtomValue(isStudioModalOpenAtom); + const fileViewerModalOpen = useAtomValue(taskFileViewerAtom).isModalOpen; + const filePreviewOpen = useAtomValue(filePreviewAtom).isOpen; + return studioModalOpen || fileViewerModalOpen || filePreviewOpen; +} From 099a7d0ac183d481184523ae9ad1484c07ef7203 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Wed, 5 Aug 2026 11:47:03 -0500 Subject: [PATCH 2/3] studio: park the browser guest under the command palette too The palette is the same shape as the dialogs the coverage hook already knows about -- a Radix dialog with a dim layer, mounted app-wide -- but its open state is its own atom rather than the studio-modal slot, so nothing was reporting it. Cmd+K over a browser panel left the page painting over the palette. --- apps/studio/src/client/hooks/use-guest-covered.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/studio/src/client/hooks/use-guest-covered.ts b/apps/studio/src/client/hooks/use-guest-covered.ts index 17c784cd5..9a6c068a0 100644 --- a/apps/studio/src/client/hooks/use-guest-covered.ts +++ b/apps/studio/src/client/hooks/use-guest-covered.ts @@ -1,3 +1,4 @@ +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"; @@ -13,13 +14,18 @@ import { useAtomValue } from "jotai"; * tab switch, which is the only park signal `useBrowserSlot` otherwise receives, * so a covered host has to say so itself. * - * All three sources, because they are independent slots and a menu accelerator + * 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 file viewer's expand modal, and the chat's image/diagram preview. + * ...), 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 || fileViewerModalOpen || filePreviewOpen; + return ( + studioModalOpen || commandMenuOpen || fileViewerModalOpen || filePreviewOpen + ); } From b322bedf2dc6876d88dbc9ebcfa51a2055ab7ca8 Mon Sep 17 00:00:00 2001 From: Jeremy Mack Date: Wed, 5 Aug 2026 11:47:14 -0500 Subject: [PATCH 3/3] studio: stop a stale guest's load event clearing the live failure The panel's webview listeners outlive `targetId` changing in place, by the frame between the render and the effect teardown. A did-navigate or did-start-loading arriving from the previous guest in that window cleared the failure whatever it was stamped with, so returning to a failed session while the agent drove the one you left dropped its error notice and unparked its guest over the slot. The clear is now guarded on the listener's own guest, matching the write side. --- .../client/components/task/browser-panel.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/client/components/task/browser-panel.tsx b/apps/studio/src/client/components/task/browser-panel.tsx index ad7aec4c4..8b2c4dd03 100644 --- a/apps/studio/src/client/components/task/browser-panel.tsx +++ b/apps/studio/src/client/components/task/browser-panel.tsx @@ -181,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 = () => { - setFailure(null); + clearFailure(); sync(); }; - const onStartLoading = () => { - setFailure(null); - }; const onFailLoad = (event: Event) => { const detail = event as DidFailLoadEvent; // Ignore sub-frame failures and user-aborted navigations (ERR_ABORTED), @@ -215,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]);