From 1f3929bda1370f014abe253af322afa517a162c4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 05:56:45 -0700 Subject: [PATCH] [eric] browser: only keep AGENT-driven browsers alive across dashboard switches; manual ones don't render off their home (no bleed, reload on return) --- .../app/pages/Dashboard/cards/BrowserCard.tsx | 3 +-- .../hooks/lifecycle/useDashboardLifecycle.ts | 9 +++++++-- .../hooks/state/useDashboardSelectors.ts | 7 ++++--- frontend/src/shared/isAgentDrivingBrowser.ts | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 frontend/src/shared/isAgentDrivingBrowser.ts diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index d95081b0..e433ffea 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1160,8 +1160,7 @@ const BrowserCard: React.FC = ({ width: '100%', height: '100%', border: 'none', - // A foreign (kept-alive) card lives on another dashboard: hide its surface the SAME proven way inactive tabs do (visibility:hidden reliably stops the guest compositing yet keeps the webContents + session alive), not just the off-screen card position, a heavy live page (Discord) can leave its OS-level webview surface painted at the old spot even after the card moves off-screen. Skip while an agent is driving it: a hidden guest may not paint frames for the agent's screenshots. - visibility: ((keepAliveHidden && !agentActive) || tab.id !== activeTabId) ? 'hidden' : 'visible', + visibility: tab.id === activeTabId ? 'visible' : 'hidden', zIndex: tab.id === activeTabId ? 1 : 0, // Only during select mode does the page go click-through, so the element picker can grab the whole card from anywhere instead of just the header (a live webview swallows host clicks). Off select mode = live for browsing. pointerEvents: isElementSelectMode ? 'none' : 'auto', diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index b066fb75..1269d82e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -26,7 +26,7 @@ import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/wo import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; -import { getKeepAliveBrowserIds } from '@/shared/browserFocus'; +import { isAgentDrivingBrowser } from '@/shared/isAgentDrivingBrowser'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; @@ -99,7 +99,12 @@ export function useDashboardLifecycle({ hasFittedRef.current = false; restoredExpandedRef.current = false; setOutputsRefetched(false); - dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() })); + // Only keep AGENT-driven browsers alive across the switch; a manual browser is dropped here (reloads when you return) so its kept-alive surface can't bleed onto the dashboard you land on. + const st = store.getState(); + const agentLiveIds = Object.keys(st.dashboardLayout.browserCards).filter( + (id) => isAgentDrivingBrowser(st.agents.sessions, id, st.dashboardLayout.browserCards[id]?.spawned_by), + ); + dispatch(resetLayout({ keepBrowserIds: agentLiveIds })); // CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer. dispatch(fetchSessions({ dashboardId })); dispatch(fetchLayout({ dashboardId })); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index fcde4c3f..8b316617 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useAppSelector } from '@/shared/hooks'; +import { isAgentDrivingBrowser } from '@/shared/isAgentDrivingBrowser'; // All of the dashboard's Redux reads in one place. Keeps Dashboard.tsx a thin composition layer instead of a 25-line selector wall. export function useDashboardSelectors(dashboardId: string) { @@ -19,14 +20,14 @@ export function useDashboardSelectors(dashboardId: string) { } return out; }, [allBrowserCards, dashboardId]); - // Keep-alive browser cards from OTHER dashboards still in state (resetLayout preserved them across the switch). Rendered mounted-but-hidden by the card layer so their webContents + sessionStorage survive; kept OUT of `browserCards` so save/bounds/keyboard-nav only ever see THIS dashboard's cards (no cross-dashboard leak). + // Only an AGENT-driven browser from another dashboard stays mounted-but-hidden here so its run keeps going in the background; a MANUAL browser is deliberately NOT rendered off its own dashboard (it reloads on return) because a kept-alive heavy page bleeds its webview surface onto whatever dashboard you're viewing. Kept OUT of `browserCards` so save/bounds/keyboard-nav only ever see THIS dashboard's cards. const keepAliveBrowserCards = useMemo(() => { const out: typeof allBrowserCards = {}; for (const [id, bc] of Object.entries(allBrowserCards)) { - if (bc.dashboard_id && bc.dashboard_id !== dashboardId) out[id] = bc; + if (bc.dashboard_id && bc.dashboard_id !== dashboardId && isAgentDrivingBrowser(sessions, id, bc.spawned_by)) out[id] = bc; } return out; - }, [allBrowserCards, dashboardId]); + }, [allBrowserCards, dashboardId, sessions]); const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards); const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub); const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); diff --git a/frontend/src/shared/isAgentDrivingBrowser.ts b/frontend/src/shared/isAgentDrivingBrowser.ts new file mode 100644 index 00000000..1a40ad58 --- /dev/null +++ b/frontend/src/shared/isAgentDrivingBrowser.ts @@ -0,0 +1,19 @@ +import type { AgentSession } from '@/shared/state/agentsSlice'; + +const ACTIVE_STATUSES = new Set(['running', 'waiting_approval']); + +// True while an agent is actively running against this browser, so its webContents must survive a dashboard switch (the run keeps going in the background and the agent reaches it over CDP). A MANUAL browser is false: it stops rendering the moment you leave its dashboard, so it can't bleed onto another, and it reloads when you come back. +export function isAgentDrivingBrowser( + sessions: Record, + browserId: string, + spawnedBy?: string | null, +): boolean { + for (const s of Object.values(sessions)) { + if (s.browser_id === browserId && ACTIVE_STATUSES.has(s.status)) return true; + } + if (spawnedBy) { + const parent = sessions[spawnedBy]; + if (parent && ACTIVE_STATUSES.has(parent.status)) return true; + } + return false; +}