import { useCallback, useEffect, useRef, useState } from "react"; import type { BrowserNavState, BrowserRect } from "../../main/browser-view-host"; export type { BrowserNavState }; type UseBrowserViewOptions = { sessionId: string; active: boolean; poppedOut: boolean; /** * Preview target driven by the daemon (via `ao preview`, streamed over CDC). * When set, the view navigates here automatically; an empty value clears it. */ previewUrl?: string; /** * Monotonic counter the daemon bumps on every `ao preview` call, even when * previewUrl is unchanged. The view re-navigates whenever it advances, so a * repeated `ao preview ` still refreshes (and CDC replays of an * unrelated session update, which leave it unchanged, are ignored). */ previewRevision?: number; }; export type BrowserViewModel = { viewId: string; navState: BrowserNavState; slotRef: (node: HTMLDivElement | null) => void; navigate: (url: string) => Promise; goBack: () => Promise; goForward: () => Promise; reload: () => Promise; stop: () => Promise; destroy: () => void; }; const EMPTY_NAV_STATE: BrowserNavState = { viewId: "", url: "", title: "", canGoBack: false, canGoForward: false, isLoading: false, }; 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, poppedOut, previewUrl, previewRevision, }: UseBrowserViewOptions): BrowserViewModel { const [viewId, setViewId] = useState(""); const [navState, setNavState] = useState(EMPTY_NAV_STATE); const slotNodeRef = useRef(null); const viewIdRef = useRef(""); const activeRef = useRef(active); const frameRef = useRef(null); const observerRef = useRef(null); const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null); useEffect(() => { activeRef.current = active; }, [active]); const sendHiddenBounds = useCallback((id = viewIdRef.current) => { if (!id) return; window.ao?.browser.setBounds({ viewId: id, rect: HIDDEN_RECT, visible: false }); }, []); const measureAndSend = useCallback(() => { frameRef.current = null; const id = viewIdRef.current; const node = slotNodeRef.current; if (!id) return; if (!activeRef.current || !node || !node.isConnected) { sendHiddenBounds(id); return; } const rect = visibleSlotRect(node); const payload = { viewId: id, rect, visible: rect.width > 0 && rect.height > 0, }; window.ao?.browser.setBounds(payload); }, [sendHiddenBounds]); const cancelScheduledMeasure = useCallback(() => { if (frameRef.current === null) return; if (window.cancelAnimationFrame) { window.cancelAnimationFrame(frameRef.current); } window.clearTimeout(frameRef.current); frameRef.current = null; }, []); const scheduleMeasure = useCallback(() => { if (frameRef.current !== null) return; frameRef.current = window.requestAnimationFrame ? window.requestAnimationFrame(() => measureAndSend()) : window.setTimeout(() => measureAndSend(), 16); }, [measureAndSend]); const slotRef = useCallback( (node: HTMLDivElement | null) => { observerRef.current?.disconnect(); slotNodeRef.current = node; 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(); }, [scheduleMeasure], ); useEffect(() => { let disposed = false; window.ao?.browser.ensure(sessionId).then((state) => { if (disposed) return; viewIdRef.current = state.viewId; setViewId(state.viewId); setNavState(state); scheduleMeasure(); }); return () => { disposed = true; const id = viewIdRef.current; if (id) { sendHiddenBounds(id); } viewIdRef.current = ""; }; }, [scheduleMeasure, sendHiddenBounds, sessionId]); useEffect(() => { return window.ao?.browser.onNavState((state) => { if (state.viewId !== viewIdRef.current) return; setNavState(state); }); }, []); useEffect(() => { if (active) { scheduleMeasure(); } else { sendHiddenBounds(); } }, [active, poppedOut, scheduleMeasure, sendHiddenBounds]); useEffect(() => { const handle = () => scheduleMeasure(); window.addEventListener("resize", handle); window.addEventListener("scroll", handle, true); return () => { window.removeEventListener("resize", handle); window.removeEventListener("scroll", handle, true); observerRef.current?.disconnect(); cancelScheduledMeasure(); }; }, [cancelScheduledMeasure, scheduleMeasure]); const withView = useCallback(async (fn: (id: string) => Promise) => { const id = viewIdRef.current; if (!id) return; const next = await fn(id); if (next) setNavState(next); }, []); const navigate = useCallback( (url: string) => withView((id) => window.ao!.browser.navigate({ viewId: id, url })), [withView], ); const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]); // Drive the view from the daemon-set preview target. Current daemons key // this on previewRevision (bumped on every `ao preview` call); older daemons // did not send it, so fall back to URL changes for compatibility. useEffect(() => { if (!viewId) return; const target = previewUrl?.trim() ?? ""; const revision = typeof previewRevision === "number" ? previewRevision : null; const previous = previewTriggerRef.current; if (previous?.revision === revision && previous.target === target) return; if (revision !== null && previous?.revision === revision) return; previewTriggerRef.current = { revision, target }; if (target) { void navigate(target); } else if ((revision !== null && revision > 0) || previous?.target) { void clear(); } }, [clear, navigate, previewRevision, previewUrl, viewId]); const destroy = useCallback(() => { const id = viewIdRef.current; if (!id) return; sendHiddenBounds(id); window.ao?.browser.destroy(id); viewIdRef.current = ""; }, [sendHiddenBounds]); return { viewId, navState, slotRef, navigate, goBack: () => withView((id) => window.ao!.browser.goBack(id)), goForward: () => withView((id) => window.ao!.browser.goForward(id)), reload: () => withView((id) => window.ao!.browser.reload(id)), stop: () => withView((id) => window.ao!.browser.stop(id)), destroy, }; }