diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index 328b2b3c..f5d3b38a 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -97,12 +97,25 @@ export class BrowserViewManager extends EventEmitter { const emit = () => this.emit('state-updated', this.snapshotState()); - wc.on('did-navigate', emit); - wc.on('did-navigate-in-page', emit); - wc.on('did-start-loading', emit); - wc.on('did-stop-loading', emit); - wc.on('did-finish-load', emit); - wc.on('did-fail-load', emit); + // Defensively re-apply bounds on navigation events. Electron's + // WebContentsView is known to occasionally reset its laid-out bounds on + // navigation (a behavior carried over from the deprecated BrowserView), + // which manifests as the view "spilling" outside its intended pane. + // Re-applying after every navigation/load event is cheap and idempotent. + const reapplyBounds = () => { + if (this.attached && this.bounds.width > 0 && this.bounds.height > 0) { + view.setBounds(this.bounds); + } + }; + + wc.on('did-start-navigation', reapplyBounds); + wc.on('did-navigate', () => { reapplyBounds(); emit(); }); + wc.on('did-navigate-in-page', () => { reapplyBounds(); emit(); }); + wc.on('did-start-loading', () => { reapplyBounds(); emit(); }); + wc.on('did-stop-loading', () => { reapplyBounds(); emit(); }); + wc.on('did-finish-load', () => { reapplyBounds(); emit(); }); + wc.on('did-frame-finish-load', reapplyBounds); + wc.on('did-fail-load', () => { reapplyBounds(); emit(); }); wc.on('page-title-updated', emit); // Pop-ups / target="_blank" — hand off to the OS browser for now. @@ -121,10 +134,16 @@ export class BrowserViewManager extends EventEmitter { const contentView = this.window.contentView; if (visible) { + // Order: attach FIRST, then setBounds. Calling setBounds on an + // unattached WebContentsView can leave it in a state where the next + // attach uses default bounds, blanking the renderer area. if (!this.attached) { contentView.addChildView(view); this.attached = true; } + // The renderer only asks us to show the view after it has pushed a + // fresh non-zero rect, so applying the cached bounds here should land + // the surface in the correct pane immediately on attach. view.setBounds(this.bounds); this.visible = true; @@ -144,7 +163,10 @@ export class BrowserViewManager extends EventEmitter { setBounds(bounds: BrowserBounds): void { this.bounds = bounds; - if (this.view && this.visible) { + // Only apply to the view if it's currently attached and visible. + // Applying to a detached view appears to put Electron's WebContentsView + // into a bad state on subsequent attach (renderer area blanks out). + if (this.view && this.attached && this.visible) { this.view.setBounds(bounds); } } diff --git a/apps/x/apps/preload/src/preload.ts b/apps/x/apps/preload/src/preload.ts index 7d7d53e4..bc69d4bb 100644 --- a/apps/x/apps/preload/src/preload.ts +++ b/apps/x/apps/preload/src/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, webUtils } from 'electron'; +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'; import { ipc as ipcShared } from '@x/shared'; type InvokeChannels = ipcShared.InvokeChannels; @@ -55,4 +55,5 @@ contextBridge.exposeInMainWorld('ipc', ipc); contextBridge.exposeInMainWorld('electronUtils', { getPathForFile: (file: File) => webUtils.getPathForFile(file), -}); \ No newline at end of file + getZoomFactor: () => webFrame.getZoomFactor(), +}); diff --git a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx index e0788074..bdfd4b4a 100644 --- a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx @@ -39,58 +39,78 @@ interface BrowserPaneProps { export function BrowserPane({ onClose }: BrowserPaneProps) { const [state, setState] = useState(EMPTY_STATE) const [addressValue, setAddressValue] = useState('') - const [addressFocused, setAddressFocused] = useState(false) - const containerRef = useRef(null) + const addressFocusedRef = useRef(false) const viewportRef = useRef(null) const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null) + const viewVisibleRef = useRef(false) // ── Subscribe to state updates from main ────────────────────────────────── useEffect(() => { const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => { const next = incoming as BrowserState setState(next) + if (!addressFocusedRef.current) { + setAddressValue(next.url) + } }) // Kick an initial state fetch so the chrome reflects wherever the view // was left from a previous session (or empty, if never loaded). void window.ipc.invoke('browser:getState', null).then((initial) => { - setState(initial as BrowserState) + const next = initial as BrowserState + setState(next) + if (!addressFocusedRef.current) { + setAddressValue(next.url) + } }) return cleanup }, []) - // Keep the address bar in sync with the active page URL, but only when the - // input isn't focused — don't clobber whatever the user is typing. - useEffect(() => { - if (!addressFocused) { - setAddressValue(state.url) - } - }, [state.url, addressFocused]) - - // ── Visibility lifecycle ────────────────────────────────────────────────── - // Show on mount, hide on unmount. The WebContentsView is expensive to - // create but cheap to show/hide. - useEffect(() => { - void window.ipc.invoke('browser:setVisible', { visible: true }) - return () => { - void window.ipc.invoke('browser:setVisible', { visible: false }) - } - }, []) - // ── Bounds tracking ─────────────────────────────────────────────────────── // The main process needs pixel-accurate bounds *relative to the window // content area*. getBoundingClientRect() returns viewport-relative coords, - // which in Electron with no custom chrome equal content-area coords. - const pushBounds = useCallback(() => { + // which in Electron with hiddenInset titleBar equal content-area coords. + // + // Reads layout synchronously and posts an IPC update only when the rect + // actually changed. Cheap enough to call from a RAF loop or observer. + const setViewVisible = useCallback((visible: boolean) => { + if (viewVisibleRef.current === visible) return + viewVisibleRef.current = visible + void window.ipc.invoke('browser:setVisible', { visible }) + }, []) + + const measureBounds = useCallback(() => { const el = viewportRef.current - if (!el) return + if (!el) return null + const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01) const rect = el.getBoundingClientRect() + const chatSidebar = el.ownerDocument.querySelector('[data-chat-sidebar-root]') + const chatSidebarRect = chatSidebar?.getBoundingClientRect() + const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0 + ? Math.min(rect.right, chatSidebarRect.left) + : rect.right + // `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's + // native view bounds are in unzoomed window coordinates, so we have to + // convert back using the renderer zoom factor. + const left = Math.ceil(rect.left * zoomFactor) + const top = Math.ceil(rect.top * zoomFactor) + const right = Math.floor(clampedRightCss * zoomFactor) + const bottom = Math.floor(rect.bottom * zoomFactor) + const width = right - left + const height = bottom - top + // A zero-sized rect means the element isn't laid out yet or has been + // collapsed behind the chat pane — hide the native view in that case. + if (width <= 0 || height <= 0) return null const bounds = { - x: Math.round(rect.left), - y: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), + x: left, + y: top, + width, + height, } + return bounds + }, []) + + const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => { const last = lastBoundsRef.current if ( last && @@ -99,36 +119,96 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { last.width === bounds.width && last.height === bounds.height ) { - return + return bounds } lastBoundsRef.current = bounds void window.ipc.invoke('browser:setBounds', bounds) + return bounds }, []) + const syncView = useCallback(() => { + const bounds = measureBounds() + if (!bounds) { + lastBoundsRef.current = null + setViewVisible(false) + return null + } + pushBounds(bounds) + setViewVisible(true) + return bounds + }, [measureBounds, pushBounds, setViewVisible]) + + // Defensively re-push bounds whenever the underlying page state changes. + // Electron's WebContentsView can drop its laid-out rect on navigation, + // and even though main re-applies on the same events, pushing from the + // renderer ensures we use the *current* layout (in case the chat sidebar + // or window resized while the page was loading). + useEffect(() => { + syncView() + }, [state.url, state.loading, syncView]) + + // ── Visibility lifecycle ────────────────────────────────────────────────── + // Order matters: push bounds FIRST so the WCV is created at the right + // position, THEN setVisible. Otherwise the view gets attached at stale + // (or zero) cached bounds and visually spills until the next bounds push. + // + // We wait one animation frame after mount so React's commit + the browser's + // layout pass for any sibling that just mounted (e.g. the chat sidebar) are + // both reflected in getBoundingClientRect. A single RAF is enough — by then + // the renderer has painted the post-commit DOM at least once. + useEffect(() => { + let cancelled = false + const rafId = requestAnimationFrame(() => { + if (cancelled) return + syncView() + }) + return () => { + cancelled = true + cancelAnimationFrame(rafId) + lastBoundsRef.current = null + setViewVisible(false) + } + }, [setViewVisible, syncView]) + + // Ongoing bounds tracking. Observes everything that can move our viewport: + // - the viewport div itself (local size changes) + // - the SidebarInset ancestor (changes when chat sidebar mounts/resizes) + // - the document root (window resize, devicePixelRatio changes) + // + // ResizeObserver fires after layout but before paint, so by the time we + // call pushBounds the rect is current. Multiple observers may fire in the + // same frame — RAF-coalesce them so we only post one IPC per frame. useEffect(() => { const el = viewportRef.current if (!el) return - // Initial push + ResizeObserver for size changes. - pushBounds() - const ro = new ResizeObserver(() => pushBounds()) + // The sidebar-inset main element is the immediate flex parent whose + // width shrinks when a sibling appears. Walking up the tree is more + // robust than passing a ref through props. + const sidebarInset = el.closest('[data-slot="sidebar-inset"]') + const chatSidebar = el.ownerDocument.querySelector('[data-chat-sidebar-root]') + const documentElement = el.ownerDocument.documentElement + + let pendingRaf: number | null = null + const schedule = () => { + if (pendingRaf !== null) return + pendingRaf = requestAnimationFrame(() => { + pendingRaf = null + syncView() + }) + } + + const ro = new ResizeObserver(schedule) ro.observe(el) - - // The container may move without resizing (sidebar collapse, window - // resize). Listen on window resize for that case. - const onWindowResize = () => pushBounds() - window.addEventListener('resize', onWindowResize) - - // Also poll briefly during layout transitions (the sidebar uses CSS - // transitions that don't fire ResizeObserver every frame). - const interval = window.setInterval(pushBounds, 100) + if (sidebarInset) ro.observe(sidebarInset) + if (chatSidebar) ro.observe(chatSidebar) + ro.observe(documentElement) return () => { + if (pendingRaf !== null) cancelAnimationFrame(pendingRaf) ro.disconnect() - window.removeEventListener('resize', onWindowResize) - window.clearInterval(interval) } - }, [pushBounds]) + }, [syncView]) // ── Actions ─────────────────────────────────────────────────────────────── const handleSubmitAddress = useCallback((e: React.FormEvent) => { @@ -157,8 +237,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { return (
{/* Chrome row: back / forward / reload / address bar */}
setAddressValue(e.target.value)} onFocus={(e) => { - setAddressFocused(true) + addressFocusedRef.current = true e.currentTarget.select() }} - onBlur={() => setAddressFocused(false)} + onBlur={() => { + addressFocusedRef.current = false + setAddressValue(state.url) + }} placeholder="Enter URL or search..." className={cn( 'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground', @@ -242,7 +324,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { user sees the web page, not a React element. */}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index f94c94ba..e51d7c8f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -76,12 +76,18 @@ function matchBillingError(message: string) { return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null } +interface BillingRowboatAccount { + config?: { + appUrl?: string | null + } | null +} + function BillingErrorCTA({ label }: { label: string }) { const [appUrl, setAppUrl] = useState(null) useEffect(() => { window.ipc.invoke('account:getRowboat', null) - .then((account: any) => setAppUrl(account.config?.appUrl ?? null)) + .then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null)) .catch(() => {}) }, []) @@ -467,6 +473,7 @@ export function ChatSidebar({ return (
string; + getZoomFactor: () => number; }; } } -export { }; \ No newline at end of file +export { };