From 707836d9261eea5c209cba3b5a801573c1963c8b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 09:53:26 -0700 Subject: [PATCH] [eric] canvas: suspend hook back under the file cap, visibility hook and geometry split out --- .../hooks/interaction/suspendGeometry.ts | 26 +++++++++++ .../hooks/interaction/useAppHidden.ts | 22 +++++++++ .../hooks/interaction/useWebviewSuspend.ts | 45 ++----------------- 3 files changed, 52 insertions(+), 41 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/hooks/interaction/suspendGeometry.ts create mode 100644 frontend/src/app/pages/Dashboard/hooks/interaction/useAppHidden.ts diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/suspendGeometry.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/suspendGeometry.ts new file mode 100644 index 00000000..2ba47bc0 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/suspendGeometry.ts @@ -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; +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useAppHidden.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useAppHidden.ts new file mode 100644 index 00000000..34bc374e --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useAppHidden.ts @@ -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 | 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; +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index bf50d6b5..c6f083c2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -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>({}); const vpRef = useRef({ panX, panY, zoom, vpW: 1200, vpH: 800 }); - const [appHidden, setAppHidden] = useState(false); - useEffect(() => { - if (!isElectron) return; - let t: ReturnType | 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;