fix spillage

This commit is contained in:
Arjun 2026-04-10 11:12:51 +05:30
parent b0f5ed85c6
commit 8991c562d0
5 changed files with 173 additions and 60 deletions

View file

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

View file

@ -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),
});
getZoomFactor: () => webFrame.getZoomFactor(),
});

View file

@ -39,58 +39,78 @@ interface BrowserPaneProps {
export function BrowserPane({ onClose }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
const [addressFocused, setAddressFocused] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const addressFocusedRef = useRef(false)
const viewportRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[data-slot="sidebar-inset"]')
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[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 (
<div
ref={containerRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
>
{/* Chrome row: back / forward / reload / address bar */}
<div
@ -207,10 +286,13 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
value={addressValue}
onChange={(e) => 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. */}
<div
ref={viewportRef}
className="relative min-h-0 flex-1"
className="relative min-h-0 min-w-0 flex-1"
data-browser-viewport
/>
</div>

View file

@ -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<string | null>(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 (
<div
ref={paneRef}
data-chat-sidebar-root
onMouseDownCapture={onActivate}
onFocusCapture={onActivate}
className={cn(

View file

@ -35,8 +35,9 @@ declare global {
};
electronUtils: {
getPathForFile: (file: File) => string;
getZoomFactor: () => number;
};
}
}
export { };
export { };