diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index e433ffea..145d84d0 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -729,6 +729,8 @@ const BrowserCard: React.FC = ({ data-select-type="browser-card" data-select-id={browserId} data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })} + // Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing. + data-keepalive-hidden={keepAliveHidden ? '1' : undefined} onPointerDownCapture={() => onBringToFront?.(browserId, 'browser')} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 20fdfb6c..4030df46 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -543,6 +543,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const prev = stateRef.current; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let i = 0; i < children.length; i++) { + const child = children[i] as HTMLElement; + // Skip a kept-alive browser card from another dashboard (parked off-screen): fitting to it pans the canvas right onto it, which is the cross-dashboard bleed. On an empty dashboard this leaves nothing to fit, so the !isFinite reset below restores an identity transform and the off-screen card stays off-screen. + if (child.getAttribute?.('data-keepalive-hidden') === '1' || child.querySelector?.('[data-keepalive-hidden="1"]')) continue; const r = children[i].getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; const sx = (r.left - vRect.left - prev.panX) / prev.zoom; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 1269d82e..b066fb75 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 { isAgentDrivingBrowser } from '@/shared/isAgentDrivingBrowser'; +import { getKeepAliveBrowserIds } from '@/shared/browserFocus'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; @@ -99,12 +99,7 @@ export function useDashboardLifecycle({ hasFittedRef.current = false; restoredExpandedRef.current = false; setOutputsRefetched(false); - // 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 })); + dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() })); // 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 8b316617..df3decbf 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -1,6 +1,5 @@ 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) { @@ -20,14 +19,14 @@ export function useDashboardSelectors(dashboardId: string) { } return out; }, [allBrowserCards, dashboardId]); - // 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. + // Browser cards from OTHER dashboards stay mounted (so their webContents + session survive a switch, no Discord logout) but get rendered parked far off-screen by the card layer; that off-screen park reliably hides even a heavy live page (Discord), CDP-verified. Kept OUT of `browserCards` so save/bounds/keyboard-nav only ever see THIS dashboard's cards (no cross-dashboard leak), and tagging every card's home dashboard is what stops the real bleed (an untagged card renders as home everywhere). const keepAliveBrowserCards = useMemo(() => { const out: typeof allBrowserCards = {}; for (const [id, bc] of Object.entries(allBrowserCards)) { - if (bc.dashboard_id && bc.dashboard_id !== dashboardId && isAgentDrivingBrowser(sessions, id, bc.spawned_by)) out[id] = bc; + if (bc.dashboard_id && bc.dashboard_id !== dashboardId) out[id] = bc; } return out; - }, [allBrowserCards, dashboardId, sessions]); + }, [allBrowserCards, dashboardId]); 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 deleted file mode 100644 index 1a40ad58..00000000 --- a/frontend/src/shared/isAgentDrivingBrowser.ts +++ /dev/null @@ -1,19 +0,0 @@ -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; -}