From ef03ea71bbb3cd2cffee95f34edc91b897647e61 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 01:04:59 -0700 Subject: [PATCH] [eric] browser: keep recent browsers alive across dashboard switches so sessions survive --- .../Dashboard/canvas/DashboardCanvas.tsx | 11 +++++-- .../Dashboard/canvas/DashboardCardLayer.tsx | 6 +++- .../app/pages/Dashboard/cards/BrowserCard.tsx | 7 ++++- .../hooks/interaction/useWebviewSuspend.ts | 16 ++++++---- .../hooks/lifecycle/useDashboardLifecycle.ts | 3 +- .../hooks/state/useDashboardController.ts | 4 +-- .../hooks/state/useDashboardSelectors.ts | 9 ++++++ frontend/src/shared/browserFocus.ts | 24 ++++++++++++--- frontend/src/shared/browserTeardown.ts | 2 ++ .../src/shared/state/dashboardLayoutSlice.ts | 30 ++++++++++++------- 10 files changed, 85 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 4e09d4e1..b6f5928c 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -39,6 +39,7 @@ interface DashboardCanvasProps { cards: Record; viewCards: Record; browserCards: Record; + keepAliveBrowserCards: Record; notes: Record; workflowCards: Record; workflowsHub: WorkflowsHubPosition | null; @@ -101,6 +102,7 @@ const DashboardCanvas: React.FC = ({ cards, viewCards, browserCards, + keepAliveBrowserCards, notes, workflowCards, workflowsHub, @@ -225,9 +227,8 @@ const DashboardCanvas: React.FC = ({ }} /> - {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? ( - - ) : ( + {/* Card layer always mounts, even on an empty dashboard, so keep-alive browser cards from other dashboards stay alive; the empty-state overlays it below. */} + {(
= ({ cards={cards} viewCards={viewCards} browserCards={browserCards} + keepAliveBrowserCards={keepAliveBrowserCards} notes={notes} workflowCards={workflowCards} workflowsHub={workflowsHub} @@ -276,6 +278,9 @@ const DashboardCanvas: React.FC = ({ />
)} + {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub && ( + + )} ; viewCards: Record; browserCards: Record; + keepAliveBrowserCards: Record; notes: Record; workflowCards: Record; workflowsHub: WorkflowsHubPosition | null; @@ -68,6 +69,7 @@ const DashboardCardLayer: React.FC = ({ cards, viewCards, browserCards, + keepAliveBrowserCards, notes, workflowCards, workflowsHub, @@ -217,9 +219,11 @@ const DashboardCardLayer: React.FC = ({ /> ); })} - {Object.values(browserCards).map((bc) => ( + {/* One map over active + keep-alive cards: a card switching from active to hidden keeps its key + tree slot, so React never remounts it (a remount = new webview = lost session). Cross-dashboard ones render keepAliveHidden. */} + {Object.values({ ...browserCards, ...keepAliveBrowserCards }).map((bc) => ( void; onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void; onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; @@ -169,7 +171,7 @@ interface Props { const BrowserCard: React.FC = ({ browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false, - isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, + isSelected = false, isHighlighted = false, keepAliveHidden = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, cardZOrder = 0, onDoubleClick, onBringToFront, }) => { const c = useClaudeTokens(); @@ -738,6 +740,9 @@ const BrowserCard: React.FC = ({ }} sx={{ position: 'absolute', + // Kept-alive card from another dashboard: invisible + click-through (webContents stays live so its session survives), but never unmounted. + visibility: keepAliveHidden ? 'hidden' : undefined, + pointerEvents: keepAliveHidden ? 'none' : undefined, // contain: webview repaints don't shake neighbor cards. contain: 'layout style', // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index 036e9188..31a89350 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -8,6 +8,7 @@ import { } from '@/shared/state/dashboardLayoutSlice'; import { getWebview } from '@/shared/browserRegistry'; import { getActivity } from '@/shared/browserCommandHandler'; +import { isKeepAliveBrowser } from '@/shared/browserFocus'; const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron'); @@ -54,6 +55,11 @@ function agentNeedsLive(browserId: string, card: BrowserCardPosition): boolean { return false; } +// A card we must never snapshot-swap: an agent is driving it, OR it's in the keep-alive set (recently used). Suspending a keep-alive card would destroy its webContents and wipe its sessionStorage (logged-in sites drop their session), the whole thing we're preventing. +function mustStayLive(browserId: string, card: BrowserCardPosition): boolean { + return agentNeedsLive(browserId, card) || isKeepAliveBrowser(browserId); +} + /** * Swaps off-screen, agent-idle webviews for static snapshots (freeing their * renderer processes) and wakes them when panned back into view. Agent-driven @@ -103,7 +109,7 @@ export function useWebviewSuspend( .filter(([, card]) => !!card) .sort((a, b) => distFromCenter(a[1], vpRef.current) - distFromCenter(b[1], vpRef.current)); for (const [id, card] of parked) { - if (agentNeedsLive(id, card)) { + if (mustStayLive(id, card)) { dispatch(resumeBrowserCard(id)); budget--; continue; @@ -121,22 +127,22 @@ export function useWebviewSuspend( for (const [id, card] of Object.entries(browserCards)) { if (isSuspended(id)) continue; if (cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX)) continue; - if (agentNeedsLive(id, card)) continue; + if (mustStayLive(id, card)) continue; const dataUrl = await captureCard(id, card); // The capture await yielded; conditions may have changed under us. - if (!dataUrl || cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX) || agentNeedsLive(id, card)) continue; + if (!dataUrl || cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX) || mustStayLive(id, card)) continue; dispatch(suspendBrowserCard({ browserId: id, dataUrl })); } const countLive = () => Object.keys(browserCards).filter((id) => !isSuspended(id)).length; if (countLive() > MAX_LIVE_WEBVIEWS) { const candidates = Object.entries(browserCards) - .filter(([id, card]) => !isSuspended(id) && !agentNeedsLive(id, card)) + .filter(([id, card]) => !isSuspended(id) && !mustStayLive(id, card)) .sort((a, b) => distFromCenter(b[1], vpRef.current) - distFromCenter(a[1], vpRef.current)); for (const [id, card] of candidates) { if (countLive() <= MAX_LIVE_WEBVIEWS) break; const dataUrl = await captureCard(id, card); - if (!dataUrl || agentNeedsLive(id, card)) continue; + if (!dataUrl || mustStayLive(id, card)) continue; dispatch(suspendBrowserCard({ browserId: id, dataUrl })); } } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index b9176b24..98963abd 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -26,6 +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 { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; @@ -98,7 +99,7 @@ export function useDashboardLifecycle({ hasFittedRef.current = false; restoredExpandedRef.current = false; setOutputsRefetched(false); - dispatch(resetLayout()); + 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/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 3740ff74..a720f681 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -29,7 +29,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { const elementSelectionCtx = useElementSelection(); const isElementSelectMode = elementSelectionCtx?.selectMode ?? false; const { - dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards, + dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards, keepAliveBrowserCards, workflowCards, workflowItems, workflowOpenCards, workflowsHub, pendingFocusWorkflowId, pendingFocusWorkflowsHub, notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds, @@ -299,7 +299,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { return { c, dashboardId, dashboardName, canvas, selection, sessions, sessionList, - cards, viewCards, browserCards, notes, outputs, glowingAgentCards, + cards, viewCards, browserCards, keepAliveBrowserCards, notes, outputs, glowingAgentCards, workflowCards, workflowsHub, expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId, focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index 740d579e..fcde4c3f 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -19,6 +19,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). + 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; + } + return out; + }, [allBrowserCards, dashboardId]); const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards); const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub); const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); @@ -46,6 +54,7 @@ export function useDashboardSelectors(dashboardId: string) { cards, viewCards, browserCards, + keepAliveBrowserCards, workflowCards, workflowItems, workflowOpenCards, diff --git a/frontend/src/shared/browserFocus.ts b/frontend/src/shared/browserFocus.ts index 4cd06a67..0a8f0deb 100644 --- a/frontend/src/shared/browserFocus.ts +++ b/frontend/src/shared/browserFocus.ts @@ -1,11 +1,13 @@ -// Tracks which browser card the user last interacted with (clicked into its page or its chrome), -// so global shortcuts (Ctrl+R reload, Ctrl +/- zoom, Ctrl+Tab) target THAT browser instead of a -// guess. Module-level and imperative on purpose: shortcut handlers read it on keydown, so no React -// re-render is needed. Cleared the moment the user clicks anything that isn't a browser card. +// The browser card you last clicked into, so global shortcuts (Ctrl+R, zoom, Ctrl+Tab) target it; imperative + read on keydown so no re-render, and cleared the moment you click off any browser card. let lastInteractedBrowserId: string | null = null; +// Recently-used browser ids, newest first; the top KEEP_ALIVE_CAP stay mounted across dashboard switches + off-screen so their sessionStorage (logins like Discord) survives, the rest get reclaimed by the normal suspend (LRU). +const KEEP_ALIVE_CAP = 4; +let recentBrowserIds: string[] = []; + export function setLastInteractedBrowser(browserId: string): void { lastInteractedBrowserId = browserId; + recentBrowserIds = [browserId, ...recentBrowserIds.filter((id) => id !== browserId)].slice(0, 32); } export function clearLastInteractedBrowser(): void { @@ -15,3 +17,17 @@ export function clearLastInteractedBrowser(): void { export function getLastInteractedBrowser(): string | null { return lastInteractedBrowserId; } + +export function getKeepAliveBrowserIds(): string[] { + return recentBrowserIds.slice(0, KEEP_ALIVE_CAP); +} + +export function isKeepAliveBrowser(browserId: string): boolean { + return getKeepAliveBrowserIds().includes(browserId); +} + +// Drop a closed browser from focus + keep-alive tracking so a dead id can't hog a slot. +export function forgetBrowser(browserId: string): void { + recentBrowserIds = recentBrowserIds.filter((id) => id !== browserId); + if (lastInteractedBrowserId === browserId) lastInteractedBrowserId = null; +} diff --git a/frontend/src/shared/browserTeardown.ts b/frontend/src/shared/browserTeardown.ts index 8e08ff92..6adfcb5e 100644 --- a/frontend/src/shared/browserTeardown.ts +++ b/frontend/src/shared/browserTeardown.ts @@ -1,6 +1,7 @@ import type { Dispatch } from '@reduxjs/toolkit'; import { removeBrowserCard } from '@/shared/state/dashboardLayoutSlice'; import { getBrowserWebviews } from '@/shared/browserRegistry'; +import { forgetBrowser } from '@/shared/browserFocus'; interface CdpBridge { cdpDetachClean?: (wcId: number) => Promise; @@ -34,5 +35,6 @@ export async function removeBrowserCardCleanly( dispatch: Dispatch, ): Promise { await detachBrowserCdp(browserId); + forgetBrowser(browserId); dispatch(removeBrowserCard(browserId)); } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index a7ed27f0..32323bc8 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1296,10 +1296,18 @@ const dashboardLayoutSlice = createSlice({ delete state.glowingAgentCards[action.payload]; }, - resetLayout(state) { + resetLayout(state, action: PayloadAction<{ keepBrowserIds?: string[] } | undefined>) { + // Keep the recently-used (keep-alive) browser cards mounted across a dashboard switch so their webContents + sessionStorage survive (logged-in sites stay logged in); everything else is wiped for the fresh load. Their suspend entry rides along so a parked one isn't silently dropped. + const keep = new Set(action.payload?.keepBrowserIds || []); + const keptBrowsers: typeof state.browserCards = {}; + const keptSuspended: typeof state.suspendedBrowserCards = {}; + for (const id of keep) { + if (state.browserCards[id]) keptBrowsers[id] = state.browserCards[id]; + if (state.suspendedBrowserCards[id]) keptSuspended[id] = state.suspendedBrowserCards[id]; + } state.cards = {}; state.viewCards = {}; - state.browserCards = {}; + state.browserCards = keptBrowsers; state.workflowCards = {}; state.workflowsHub = null; state.notes = {}; @@ -1310,7 +1318,7 @@ const dashboardLayoutSlice = createSlice({ state.nextZOrder = 1; state.initialized = false; state.pendingFocusNoteId = null; - state.suspendedBrowserCards = {}; + state.suspendedBrowserCards = keptSuspended; state.endingBrowserCards = {}; state.pendingFocusWorkflowId = null; }, @@ -1330,18 +1338,20 @@ const dashboardLayoutSlice = createSlice({ if (!isReconnectRefetch) { state.cards = action.payload.cards; state.viewCards = action.payload.viewCards; - state.browserCards = action.payload.browserCards; - for (const card of Object.values(state.browserCards)) { + // Merge, don't replace: the keep-alive browser cards resetLayout preserved are ALREADY in state.browserCards with their webContents live. Keep them and add this dashboard's saved cards on top; on overlap (switching back to their own dashboard) the live data wins so the mounted webview isn't disturbed. + const keptAlive = state.browserCards; + const incoming = action.payload.browserCards; + for (const card of Object.values(incoming)) { card.dashboard_id = ownerDashboardId; } + // New cards boot parked (no guest process, title placeholder); the suspend hook wakes viewport-sized and agent-driven ones on its first pass. NEVER re-park a live keep-alive card, that snapshot-swap would kill its session. + for (const id of Object.keys(incoming)) { + if (keptAlive[id] === undefined) state.suspendedBrowserCards[id] = { dataUrl: '', capturedAt: 0 }; + } + state.browserCards = { ...incoming, ...keptAlive }; state.workflowCards = action.payload.workflowCards || {}; state.workflowsHub = action.payload.workflowsHub || null; state.notes = action.payload.notes || {}; - // Cards boot parked (no guest process, title placeholder); the suspend hook wakes viewport-sized and agent-driven ones on its first pass. Beats mounting 100 webviews just to suspend 92 of them. - state.suspendedBrowserCards = {}; - for (const id of Object.keys(action.payload.browserCards)) { - state.suspendedBrowserCards[id] = { dataUrl: '', capturedAt: 0 }; - } } else { const occupied = collectOccupiedRects(state, action.payload.expandedSessionIds); addMissingCards(state.cards, action.payload.cards, occupied);