mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] browser: keep recent browsers alive across dashboard switches so sessions survive
This commit is contained in:
@@ -39,6 +39,7 @@ interface DashboardCanvasProps {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
keepAliveBrowserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
@@ -101,6 +102,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
keepAliveBrowserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
@@ -225,9 +227,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? (
|
||||
<DashboardEmptyState c={c} onLaunch={onToolbarSend} onStarter={onStarter} />
|
||||
) : (
|
||||
{/* 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. */}
|
||||
{(
|
||||
<div
|
||||
ref={canvas.contentRef}
|
||||
style={{
|
||||
@@ -244,6 +245,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
keepAliveBrowserCards={keepAliveBrowserCards}
|
||||
notes={notes}
|
||||
workflowCards={workflowCards}
|
||||
workflowsHub={workflowsHub}
|
||||
@@ -276,6 +278,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub && (
|
||||
<DashboardEmptyState c={c} onLaunch={onToolbarSend} onStarter={onStarter} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<DashboardOverlays
|
||||
|
||||
@@ -32,6 +32,7 @@ interface DashboardCardLayerProps {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
keepAliveBrowserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
@@ -68,6 +69,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
keepAliveBrowserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
@@ -217,9 +219,11 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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) => (
|
||||
<BrowserCard
|
||||
key={`browser-${bc.browser_id}`}
|
||||
keepAliveHidden={!!bc.dashboard_id && bc.dashboard_id !== dashboardId}
|
||||
browserId={bc.browser_id}
|
||||
tabs={bc.tabs}
|
||||
activeTabId={bc.activeTabId}
|
||||
|
||||
@@ -157,6 +157,8 @@ interface Props {
|
||||
isSelected?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
multiDragDelta?: { dx: number; dy: number } | null;
|
||||
// Belongs to a non-active dashboard but kept mounted-hidden so its webContents + sessionStorage survive the switch.
|
||||
keepAliveHidden?: boolean;
|
||||
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => 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<Props> = ({
|
||||
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<Props> = ({
|
||||
}}
|
||||
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.
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
@@ -34,5 +35,6 @@ export async function removeBrowserCardCleanly(
|
||||
dispatch: Dispatch,
|
||||
): Promise<void> {
|
||||
await detachBrowserCdp(browserId);
|
||||
forgetBrowser(browserId);
|
||||
dispatch(removeBrowserCard(browserId));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user