diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx index cc6d24558..78b4f6d1c 100644 --- a/frontend/src/renderer/hooks/useBrowserView.test.tsx +++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx @@ -89,6 +89,41 @@ describe("useBrowserView", () => { expect(result.current.viewId).toBe("42:sess-1"); }); + it("clamps the native view to its resizable-panel column when the slot overspills", async () => { + const bridge = setupBridge(); + // The slot is wider than its column (e.g. the `min-w-[280px]` wrapper on a + // narrower inspector panel). The native overlay isn't clipped by DOM + // overflow, so the reported bounds must be intersected with the column. + const column = document.createElement("div"); + column.setAttribute("data-panel", ""); + column.getBoundingClientRect = vi.fn(() => ({ + x: 100, + y: 0, + width: 150, + height: 600, + top: 0, + right: 250, + bottom: 600, + left: 100, + toJSON: () => ({}), + })); + const slot = createSlot(); + column.appendChild(slot); + document.body.appendChild(column); + + const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false })); + await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1")); + act(() => result.current.slotRef(slot)); + + await waitFor(() => + expect(bridge.setBounds).toHaveBeenCalledWith({ + viewId: "42:sess-1", + rect: { x: 100, y: 34, width: 150, height: 240 }, + visible: true, + }), + ); + }); + it("hides the native view when inactive and on unmount without destroying session state", async () => { const bridge = setupBridge(); const slot = createSlot(); diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts index a97c111f7..038992c1b 100644 --- a/frontend/src/renderer/hooks/useBrowserView.ts +++ b/frontend/src/renderer/hooks/useBrowserView.ts @@ -44,6 +44,26 @@ const EMPTY_NAV_STATE: BrowserNavState = { const HIDDEN_RECT: BrowserRect = { x: 0, y: 0, width: 0, height: 0 }; +// The native WebContentsView is a window-level overlay, so DOM `overflow: +// hidden` never clips it — it paints wherever the slot's bounding box lands. +// Inside the collapsible inspector the slot sits in a `min-w-[280px]` wrapper, +// so on a narrow panel (small window, or mid-collapse) the slot's box spills +// past its resizable-panel column. Intersect the slot box with that column so +// the view can only ever paint inside it, never over the terminal/sidebar. +function visibleSlotRect(node: HTMLElement): BrowserRect { + const rect = node.getBoundingClientRect(); + let { left, top, right, bottom } = rect; + const column = node.closest("[data-panel]"); + if (column) { + const bounds = column.getBoundingClientRect(); + left = Math.max(left, bounds.left); + top = Math.max(top, bounds.top); + right = Math.min(right, bounds.right); + bottom = Math.min(bottom, bounds.bottom); + } + return { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }; +} + export function useBrowserView({ sessionId, active, @@ -78,15 +98,10 @@ export function useBrowserView({ sendHiddenBounds(id); return; } - const rect = node.getBoundingClientRect(); + const rect = visibleSlotRect(node); const payload = { viewId: id, - rect: { - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - }, + rect, visible: rect.width > 0 && rect.height > 0, }; window.ao?.browser.setBounds(payload); @@ -115,6 +130,13 @@ export function useBrowserView({ if (node) { const observer = new ResizeObserver(scheduleMeasure); observer.observe(node); + // Also track the resizable-panel column: while the inspector + // collapse/expand animates, the slot's own width stays pinned by + // `min-w-[280px]` (so a slot-only observer never fires), but the + // column's width changes every frame. Observing it re-measures + // through the whole animation so the view never lags behind. + const column = node.closest("[data-panel]"); + if (column) observer.observe(column); observerRef.current = observer; } scheduleMeasure();