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()); const emit = () => this.emit('state-updated', this.snapshotState());
wc.on('did-navigate', emit); // Defensively re-apply bounds on navigation events. Electron's
wc.on('did-navigate-in-page', emit); // WebContentsView is known to occasionally reset its laid-out bounds on
wc.on('did-start-loading', emit); // navigation (a behavior carried over from the deprecated BrowserView),
wc.on('did-stop-loading', emit); // which manifests as the view "spilling" outside its intended pane.
wc.on('did-finish-load', emit); // Re-applying after every navigation/load event is cheap and idempotent.
wc.on('did-fail-load', emit); 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); wc.on('page-title-updated', emit);
// Pop-ups / target="_blank" — hand off to the OS browser for now. // 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; const contentView = this.window.contentView;
if (visible) { 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) { if (!this.attached) {
contentView.addChildView(view); contentView.addChildView(view);
this.attached = true; 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); view.setBounds(this.bounds);
this.visible = true; this.visible = true;
@ -144,7 +163,10 @@ export class BrowserViewManager extends EventEmitter {
setBounds(bounds: BrowserBounds): void { setBounds(bounds: BrowserBounds): void {
this.bounds = bounds; 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); 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'; import { ipc as ipcShared } from '@x/shared';
type InvokeChannels = ipcShared.InvokeChannels; type InvokeChannels = ipcShared.InvokeChannels;
@ -55,4 +55,5 @@ contextBridge.exposeInMainWorld('ipc', ipc);
contextBridge.exposeInMainWorld('electronUtils', { contextBridge.exposeInMainWorld('electronUtils', {
getPathForFile: (file: File) => webUtils.getPathForFile(file), getPathForFile: (file: File) => webUtils.getPathForFile(file),
}); getZoomFactor: () => webFrame.getZoomFactor(),
});

View file

@ -39,58 +39,78 @@ interface BrowserPaneProps {
export function BrowserPane({ onClose }: BrowserPaneProps) { export function BrowserPane({ onClose }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE) const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('') const [addressValue, setAddressValue] = useState('')
const [addressFocused, setAddressFocused] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const addressFocusedRef = useRef(false)
const viewportRef = useRef<HTMLDivElement>(null) const viewportRef = useRef<HTMLDivElement>(null)
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null) const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
const viewVisibleRef = useRef(false)
// ── Subscribe to state updates from main ────────────────────────────────── // ── Subscribe to state updates from main ──────────────────────────────────
useEffect(() => { useEffect(() => {
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => { const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
const next = incoming as BrowserState const next = incoming as BrowserState
setState(next) setState(next)
if (!addressFocusedRef.current) {
setAddressValue(next.url)
}
}) })
// Kick an initial state fetch so the chrome reflects wherever the view // Kick an initial state fetch so the chrome reflects wherever the view
// was left from a previous session (or empty, if never loaded). // was left from a previous session (or empty, if never loaded).
void window.ipc.invoke('browser:getState', null).then((initial) => { 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 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 ─────────────────────────────────────────────────────── // ── Bounds tracking ───────────────────────────────────────────────────────
// The main process needs pixel-accurate bounds *relative to the window // The main process needs pixel-accurate bounds *relative to the window
// content area*. getBoundingClientRect() returns viewport-relative coords, // content area*. getBoundingClientRect() returns viewport-relative coords,
// which in Electron with no custom chrome equal content-area coords. // which in Electron with hiddenInset titleBar equal content-area coords.
const pushBounds = useCallback(() => { //
// 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 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 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 = { const bounds = {
x: Math.round(rect.left), x: left,
y: Math.round(rect.top), y: top,
width: Math.round(rect.width), width,
height: Math.round(rect.height), height,
} }
return bounds
}, [])
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
const last = lastBoundsRef.current const last = lastBoundsRef.current
if ( if (
last && last &&
@ -99,36 +119,96 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
last.width === bounds.width && last.width === bounds.width &&
last.height === bounds.height last.height === bounds.height
) { ) {
return return bounds
} }
lastBoundsRef.current = bounds lastBoundsRef.current = bounds
void window.ipc.invoke('browser:setBounds', 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(() => { useEffect(() => {
const el = viewportRef.current const el = viewportRef.current
if (!el) return if (!el) return
// Initial push + ResizeObserver for size changes. // The sidebar-inset main element is the immediate flex parent whose
pushBounds() // width shrinks when a sibling appears. Walking up the tree is more
const ro = new ResizeObserver(() => pushBounds()) // 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) ro.observe(el)
if (sidebarInset) ro.observe(sidebarInset)
// The container may move without resizing (sidebar collapse, window if (chatSidebar) ro.observe(chatSidebar)
// resize). Listen on window resize for that case. ro.observe(documentElement)
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)
return () => { return () => {
if (pendingRaf !== null) cancelAnimationFrame(pendingRaf)
ro.disconnect() ro.disconnect()
window.removeEventListener('resize', onWindowResize)
window.clearInterval(interval)
} }
}, [pushBounds]) }, [syncView])
// ── Actions ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
const handleSubmitAddress = useCallback((e: React.FormEvent) => { const handleSubmitAddress = useCallback((e: React.FormEvent) => {
@ -157,8 +237,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
return ( return (
<div <div
ref={containerRef} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
> >
{/* Chrome row: back / forward / reload / address bar */} {/* Chrome row: back / forward / reload / address bar */}
<div <div
@ -207,10 +286,13 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
value={addressValue} value={addressValue}
onChange={(e) => setAddressValue(e.target.value)} onChange={(e) => setAddressValue(e.target.value)}
onFocus={(e) => { onFocus={(e) => {
setAddressFocused(true) addressFocusedRef.current = true
e.currentTarget.select() e.currentTarget.select()
}} }}
onBlur={() => setAddressFocused(false)} onBlur={() => {
addressFocusedRef.current = false
setAddressValue(state.url)
}}
placeholder="Enter URL or search..." placeholder="Enter URL or search..."
className={cn( className={cn(
'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground', '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. */} user sees the web page, not a React element. */}
<div <div
ref={viewportRef} ref={viewportRef}
className="relative min-h-0 flex-1" className="relative min-h-0 min-w-0 flex-1"
data-browser-viewport data-browser-viewport
/> />
</div> </div>

View file

@ -76,12 +76,18 @@ function matchBillingError(message: string) {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
} }
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
function BillingErrorCTA({ label }: { label: string }) { function BillingErrorCTA({ label }: { label: string }) {
const [appUrl, setAppUrl] = useState<string | null>(null) const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
window.ipc.invoke('account:getRowboat', null) window.ipc.invoke('account:getRowboat', null)
.then((account: any) => setAppUrl(account.config?.appUrl ?? null)) .then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {}) .catch(() => {})
}, []) }, [])
@ -467,6 +473,7 @@ export function ChatSidebar({
return ( return (
<div <div
ref={paneRef} ref={paneRef}
data-chat-sidebar-root
onMouseDownCapture={onActivate} onMouseDownCapture={onActivate}
onFocusCapture={onActivate} onFocusCapture={onActivate}
className={cn( className={cn(

View file

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