[eric] canvas: suspend hook back under the file cap, visibility hook and geometry split out

This commit is contained in:
ciregenz
2026-08-04 09:53:26 -07:00
parent 654aad67aa
commit 707836d926
3 changed files with 52 additions and 41 deletions
@@ -0,0 +1,26 @@
import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
export interface Viewport {
panX: number;
panY: number;
zoom: number;
vpW: number;
vpH: number;
}
export function cardIntersectsViewport(card: BrowserCardPosition, vp: Viewport, marginPx: number): boolean {
const m = marginPx / vp.zoom;
const vx = -vp.panX / vp.zoom - m;
const vy = -vp.panY / vp.zoom - m;
const vw = vp.vpW / vp.zoom + 2 * m;
const vh = vp.vpH / vp.zoom + 2 * m;
return card.x < vx + vw && card.x + card.width > vx && card.y < vy + vh && card.y + card.height > vy;
}
export function distFromCenter(card: BrowserCardPosition, vp: Viewport): number {
const cx = (-vp.panX + vp.vpW / 2) / vp.zoom;
const cy = (-vp.panY + vp.vpH / 2) / vp.zoom;
const dx = card.x + card.width / 2 - cx;
const dy = card.y + card.height / 2 - cy;
return dx * dx + dy * dy;
}
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
// Arc's tab-archiving tradeoff: back after a long absence pays a page reload, back quickly pays nothing.
const APP_HIDDEN_SUSPEND_MS = 10 * 60 * 1000;
/** True once the app has been hidden for 10 minutes straight; the suspend loop parks every idle webview then. */
export function useAppHidden(enabled: boolean): boolean {
const [appHidden, setAppHidden] = useState(false);
useEffect(() => {
if (!enabled) return undefined;
let t: ReturnType<typeof setTimeout> | null = null;
const onVis = (): void => {
if (t) { clearTimeout(t); t = null; }
if (document.hidden) t = setTimeout(() => setAppHidden(true), APP_HIDDEN_SUSPEND_MS);
else setAppHidden(false);
};
document.addEventListener('visibilitychange', onVis);
onVis();
return () => { document.removeEventListener('visibilitychange', onVis); if (t) clearTimeout(t); };
}, [enabled]);
return appHidden;
}
@@ -11,6 +11,8 @@ import { getActivity, isAnyBrowserBusy } from '@/shared/browserCommandHandler';
import { isKeepAliveBrowser } from '@/shared/browserFocus';
import { captureTabCapsule } from '@/shared/browserStateCapsule';
import { getMinimizedShot } from '../../desktop/minimizedShots';
import { useAppHidden } from './useAppHidden';
import { cardIntersectsViewport, distFromCenter, type Viewport } from './suspendGeometry';
const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron');
@@ -23,27 +25,6 @@ const SNAPSHOT_MAX_W = 1024;
const RESUME_MIN_CARD_PX = 220;
// Hard ceiling on simultaneous live webviews; past it the farthest-from-center non-agent card gets parked, so heavy pages degrade gracefully instead of OOMing.
const MAX_LIVE_WEBVIEWS = 8;
// App hidden this long = the user left; park every idle renderer (working agents keep theirs) so an
// all-day-open OpenSwarm stops holding gigabytes it isn't showing. Arc's tab-archiving tradeoff:
// coming back after a long absence pays a page reload, coming back quickly pays nothing.
const APP_HIDDEN_SUSPEND_MS = 10 * 60 * 1000;
interface Viewport {
panX: number;
panY: number;
zoom: number;
vpW: number;
vpH: number;
}
function cardIntersectsViewport(card: BrowserCardPosition, vp: Viewport, marginPx: number): boolean {
const m = marginPx / vp.zoom;
const vx = -vp.panX / vp.zoom - m;
const vy = -vp.panY / vp.zoom - m;
const vw = vp.vpW / vp.zoom + 2 * m;
const vh = vp.vpH / vp.zoom + 2 * m;
return card.x < vx + vw && card.x + card.width > vx && card.y < vy + vh && card.y + card.height > vy;
}
// Grace after terminal so an agent whose status blips completed->running between back-to-back turns can't lose its browser in the gap.
const WORKING_GRACE_MS = 20_000;
@@ -132,19 +113,8 @@ export function useWebviewSuspend(
const prevMinimizedRef = useRef<Record<string, boolean>>({});
const vpRef = useRef<Viewport>({ panX, panY, zoom, vpW: 1200, vpH: 800 });
const [appHidden, setAppHidden] = useState(false);
useEffect(() => {
if (!isElectron) return;
let t: ReturnType<typeof setTimeout> | null = null;
const onVis = (): void => {
if (t) { clearTimeout(t); t = null; }
if (document.hidden) t = setTimeout(() => setAppHidden(true), APP_HIDDEN_SUSPEND_MS);
else setAppHidden(false);
};
document.addEventListener('visibilitychange', onVis);
onVis();
return () => { document.removeEventListener('visibilitychange', onVis); if (t) clearTimeout(t); };
}, []);
// Hidden long enough = the user left; park every idle renderer, working agents keep theirs.
const appHidden = useAppHidden(isElectron);
// Window resize changes the viewport without touching pan/zoom/cards; tick so the evaluation below reruns, or a shrunken window never suspends anything.
const [resizeTick, setResizeTick] = useState(0);
@@ -239,13 +209,6 @@ export function useWebviewSuspend(
}, [browserCards, suspended, minimized, panX, panY, zoom, viewportRef, dispatch, resizeTick, appHidden]);
}
function distFromCenter(card: BrowserCardPosition, vp: Viewport): number {
const cx = (-vp.panX + vp.vpW / 2) / vp.zoom;
const cy = (-vp.panY + vp.vpH / 2) / vp.zoom;
const dx = card.x + card.width / 2 - cx;
const dy = card.y + card.height / 2 - cy;
return dx * dx + dy * dy;
}
// capturePage on an already-off-screen webview can HANG forever (Electron 42/Viz stops producing frames for unpainted guests), and one hung await used to wedge the whole suspend pass, silently disabling suspension for every card. Bound it hard.
const CAPTURE_TIMEOUT_MS = 1500;