diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 00406c3b..bb1c52de 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -16,6 +16,7 @@ import type { import type { Output } from '@/shared/state/outputsSlice'; import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection'; import type { useCanvasControls } from '../hooks/interaction/useCanvasControls'; +import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend'; import type { Tether } from '../geometry/dashboardTethers'; type Selection = ReturnType; @@ -141,6 +142,8 @@ const DashboardCanvas: React.FC = ({ const dotSize = Math.max(1, 1.5 * canvas.zoom); const dotSpacing = 24 * canvas.zoom; + useWebviewSuspend(browserCards, canvas.panX, canvas.panY, canvas.zoom, canvas.viewportRef); + return ( <> diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index d544eb56..4c30d271 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -25,6 +25,7 @@ import { setBrowserCardPosition, setBrowserCardSize, removeBrowserCard, + resumeBrowserCard, addBrowserTab, removeBrowserTab, setActiveBrowserTab, @@ -219,6 +220,8 @@ const BrowserCard: React.FC = ({ ); const browserAgentSession = useAppSelector(selectBrowserAgentSession); + const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); + const activity = useBrowserActivity(browserId); const agentRunning = browserAgentSession?.status === 'running'; const agentActive = activity.active || agentRunning; @@ -256,6 +259,11 @@ const BrowserCard: React.FC = ({ setRegistryActiveTab(browserId, activeTabId); }, [browserId, activeTabId]); + // A resumed webview remounts at about:blank; dropping the init markers lets doLoad re-fire. + useEffect(() => { + if (suspendedSnap) initializedTabs.current.clear(); + }, [suspendedSnap]); + const tabIdKey = tabs.map((t) => t.id).join(','); useEffect(() => { if (!isElectron) return; @@ -367,7 +375,7 @@ const BrowserCard: React.FC = ({ return () => cleanups.forEach((fn) => fn()); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabIdKey, browserId, dispatch, updateTabLocal]); + }, [tabIdKey, browserId, dispatch, updateTabLocal, suspendedSnap]); const navigate = useCallback((targetUrl: string) => { const finalUrl = resolveInput(targetUrl); @@ -379,7 +387,8 @@ const BrowserCard: React.FC = ({ }); } dispatch(updateBrowserTabUrl({ browserId, tabId: activeTabId, url: finalUrl })); - }, [browserId, activeTabId, dispatch]); + if (suspendedSnap) dispatch(resumeBrowserCard(browserId)); + }, [browserId, activeTabId, dispatch, suspendedSnap]); const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { @@ -1067,6 +1076,23 @@ const BrowserCard: React.FC = ({ )} {isElectron ? ( + suspendedSnap ? ( + dispatch(resumeBrowserCard(browserId))} + sx={{ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'top left', + cursor: 'pointer', + }} + /> + ) : ( tabs.map((tab) => ( = ({ }} /> )) + ) ) : null} vx && card.y < vy + vh && card.y + card.height > vy; +} + +function agentNeedsLive(browserId: string, card: BrowserCardPosition): boolean { + if (getActivity(browserId)) return true; + const state = store.getState(); + const glow = state.dashboardLayout.glowingBrowserCards[browserId]; + if (glow && !glow.fading) return true; + const sessions = state.agents.sessions as Record; + for (const s of Object.values(sessions)) { + if (s.browser_id === browserId && (s.status === 'running' || s.status === 'waiting_approval')) return true; + } + if (card.spawned_by) { + const parent = sessions[card.spawned_by]; + if (parent && (parent.status === 'running' || parent.status === 'waiting_approval')) return true; + } + return false; +} + +/** + * Swaps off-screen, agent-idle webviews for static snapshots (freeing their + * renderer processes) and wakes them when panned back into view. Agent-driven + * cards are never touched; commands to a suspended card wake it via + * browserCommandHandler's awaitWebview. + */ +export function useWebviewSuspend( + browserCards: Record, + panX: number, + panY: number, + zoom: number, + viewportRef: React.RefObject, +) { + const dispatch = useAppDispatch(); + const suspended = useAppSelector((s) => s.dashboardLayout.suspendedBrowserCards); + const vpRef = useRef({ panX, panY, zoom, vpW: 1200, vpH: 800 }); + + useEffect(() => { + if (!isElectron) return; + const el = viewportRef.current; + vpRef.current = { + panX, panY, zoom, + vpW: el ? el.clientWidth : 1200, + vpH: el ? el.clientHeight : 800, + }; + + for (const id of Object.keys(suspended)) { + const card = browserCards[id]; + if (card && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX)) { + dispatch(resumeBrowserCard(id)); + } + } + + const timer = setTimeout(async () => { + for (const [id, card] of Object.entries(browserCards)) { + if (store.getState().dashboardLayout.suspendedBrowserCards[id]) continue; + if (cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX)) continue; + if (agentNeedsLive(id, card)) continue; + const wv = getWebview(id, card.activeTabId); + if (!wv) continue; + try { + if (wv.isLoading()) continue; + const url = wv.getURL(); + if (!url || url === 'about:blank') continue; + const image = await wv.capturePage(); + if (image.isEmpty()) continue; + // The capture await yielded; conditions may have changed under us. + if (cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX)) continue; + if (agentNeedsLive(id, card)) continue; + const dataUrl = image.getSize().width > SNAPSHOT_MAX_W + ? image.resize({ width: SNAPSHOT_MAX_W, quality: 'good' }).toDataURL() + : image.toDataURL(); + dispatch(suspendBrowserCard({ browserId: id, dataUrl })); + } catch { + // capture failed mid-teardown or mid-navigation; card just stays live + } + } + }, SETTLE_MS); + + return () => clearTimeout(timer); + }, [browserCards, suspended, panX, panY, zoom, viewportRef, dispatch]); +} diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index 4ba60dd7..45e0d379 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -1,4 +1,6 @@ import { getWebview, type BrowserWebview } from './browserRegistry'; +import { store } from './state/store'; +import { resumeBrowserCard } from './state/dashboardLayoutSlice'; import { dashboardWs } from './ws/WebSocketManager'; import { resolveInput } from './resolveUrl'; import { rankAndCapInteractives, type RankItem } from './interactiveRanking'; @@ -1345,12 +1347,26 @@ async function handleEvaluate(wv: BrowserWebview, params: Record): // window for (re)registration before giving up, so the error stays a real // "card is gone" signal rather than a transient race. async function awaitWebview(browserId: string, tabId?: string): Promise { - const deadline = Date.now() + 2000; + // A suspended (snapshot-swapped) card has no webview at all; wake it and + // wait out the remount + page reload before the command touches it. + const wasSuspended = !!store.getState().dashboardLayout.suspendedBrowserCards[browserId]; + if (wasSuspended) store.dispatch(resumeBrowserCard(browserId)); + const deadline = Date.now() + (wasSuspended ? 12000 : 2000); let wv = getWebview(browserId, tabId); while (!wv && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 100)); wv = getWebview(browserId, tabId); } + if (wasSuspended && wv) { + while (Date.now() < deadline) { + try { + if (!wv.isLoading() && wv.getURL() !== 'about:blank') break; + } catch { + // mid-mount hiccup; keep waiting + } + await new Promise((r) => setTimeout(r, 150)); + } + } return wv; } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 4e188e0e..09cec3f9 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -92,6 +92,8 @@ export interface DashboardLayoutState { /** Transient: new browser card id; Dashboard pans/zooms to it then clears via clearPendingFocusBrowserId. */ pendingFocusBrowserId: string | null; pendingFocusNoteId: string | null; + /** Transient: snapshot stand-ins for off-screen webviews; never rides the layout PUT. */ + suspendedBrowserCards: Record; } const initialState: DashboardLayoutState = { @@ -108,6 +110,7 @@ const initialState: DashboardLayoutState = { initialized: false, pendingFocusBrowserId: null, pendingFocusNoteId: null, + suspendedBrowserCards: {}, }; interface LayoutPayload { @@ -629,6 +632,19 @@ const dashboardLayoutSlice = createSlice({ removeBrowserCard(state, action: PayloadAction) { delete state.browserCards[action.payload]; + delete state.suspendedBrowserCards[action.payload]; + }, + + suspendBrowserCard(state, action: PayloadAction<{ browserId: string; dataUrl: string }>) { + if (!state.browserCards[action.payload.browserId]) return; + state.suspendedBrowserCards[action.payload.browserId] = { + dataUrl: action.payload.dataUrl, + capturedAt: Date.now(), + }; + }, + + resumeBrowserCard(state, action: PayloadAction) { + delete state.suspendedBrowserCards[action.payload]; }, pasteBrowserCard( @@ -707,6 +723,7 @@ const dashboardLayoutSlice = createSlice({ card.tabs.splice(idx, 1); if (card.tabs.length === 0) { delete state.browserCards[action.payload.browserId]; + delete state.suspendedBrowserCards[action.payload.browserId]; return; } if (card.activeTabId === action.payload.tabId) { @@ -939,6 +956,7 @@ const dashboardLayoutSlice = createSlice({ state.nextZOrder = 1; state.initialized = false; state.pendingFocusNoteId = null; + state.suspendedBrowserCards = {}; }, }, @@ -1032,6 +1050,8 @@ export const { setBrowserCardPosition, setBrowserCardSize, removeBrowserCard, + suspendBrowserCard, + resumeBrowserCard, pasteBrowserCard, updateBrowserCardUrl, addBrowserTab,