fix(browser): clip preview view to its panel column so it can't render out of bounds (#2203)

* fix(browser): clip preview view to its panel column so it can't paint out of bounds

The inspector's Browser tab hosts a native WebContentsView positioned from the
slot's getBoundingClientRect. That overlay is a window-level layer, so DOM
overflow:hidden never clips it. The slot sits inside the inspector's
min-w-[280px] reflow guard, so on a narrow panel (small window, or mid
collapse/expand) the slot's box spills past its resizable-panel column and the
view paints over the terminal/sidebar, "out of the box" (#2202).

Two fixes in useBrowserView:
- Intersect the measured slot rect with its [data-panel] column before sending
  bounds, so the view can only ever paint inside that column.
- Observe the panel column (not just the slot) with the ResizeObserver. During
  the flex-grow collapse/expand animation the slot's width stays pinned by
  min-w, so a slot-only observer never re-fires and the bounds go stale; the
  column's width changes every frame, so observing it re-measures throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-26 19:06:56 +05:30 committed by GitHub
parent 1983e97bfa
commit acd811dce3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 64 additions and 7 deletions

View File

@ -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();

View File

@ -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<HTMLElement>("[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();