From e5b69bfa001d3cd2f2e8b9870f5dc59c953402d8 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 19:33:49 -0700 Subject: [PATCH 01/37] [eric] browser: give cards a persistent partition so logins survive reload + quit --- electron/main.js | 82 ++++++++++--------- .../app/pages/Dashboard/cards/BrowserCard.tsx | 4 + 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/electron/main.js b/electron/main.js index ac4470a7..f5242128 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,8 @@ const { app, components, BrowserWindow, ipcMain, shell, session, dialog, crashReporter, powerMonitor } = require('electron'); +// Browser cards live in their own persistent partition so cookies/localStorage/IndexedDB survive reload + quit (Discord etc. stay logged in) and site data stays isolated from the app's defaultSession. The "clear browsing data" wipe nukes only this partition. MUST match BROWSER_PARTITION in frontend BrowserCard.tsx. +const BROWSER_PARTITION = 'persist:openswarm-browser'; + // E2E flag: when OPENSWARM_E2E=1, append a Chromium command-line switch the // renderer reads at startup to set window.__OPENSWARM_E2E__ = true BEFORE any // page script parses, so the production-build store-on-window gate fires @@ -1719,45 +1722,50 @@ app.whenReady().then(async () => { try { app.dock.setIcon(iconPath); } catch (_) {} } - session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { - const allowed = [ - 'media', 'mediaKeySystem', 'protected-media-identifier', - 'geolocation', 'notifications', 'midi', 'midiSysex', - 'clipboard-read', 'clipboard-sanitized-write', - 'pointerLock', 'fullscreen', 'idle-detection', - ]; - console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied'); - callback(allowed.includes(permission)); - }); - session.defaultSession.setPermissionCheckHandler((_wc, permission) => { - const allowed = [ - 'media', 'mediaKeySystem', 'protected-media-identifier', - 'clipboard-read', 'clipboard-sanitized-write', - 'pointerLock', 'fullscreen', 'idle-detection', - ]; - return allowed.includes(permission); - }); + // Same permission grants + iframe header-strip on BOTH the app's defaultSession and the browser-card partition. A named partition is a separate session, so without re-applying these, browser cards lose camera/mic prompts and the ability to embed sites that send X-Frame-Options. + const configureBrowsingSession = (ses) => { + ses.setPermissionRequestHandler((_wc, permission, callback) => { + const allowed = [ + 'media', 'mediaKeySystem', 'protected-media-identifier', + 'geolocation', 'notifications', 'midi', 'midiSysex', + 'clipboard-read', 'clipboard-sanitized-write', + 'pointerLock', 'fullscreen', 'idle-detection', + ]; + console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied'); + callback(allowed.includes(permission)); + }); + ses.setPermissionCheckHandler((_wc, permission) => { + const allowed = [ + 'media', 'mediaKeySystem', 'protected-media-identifier', + 'clipboard-read', 'clipboard-sanitized-write', + 'pointerLock', 'fullscreen', 'idle-detection', + ]; + return allowed.includes(permission); + }); - // Strip X-Frame-Options and CSP frame-ancestors directives on iframe subframe loads so the Windows BrowserCard iframe fallback (used because tag commit segfaults on Chromium 144 + this Electron 40 CastLabs build) can render sites that normally refuse to be embedded. Scoped to types:['sub_frame'] so OAuth popups, the main app frame, deep-link redirects, and DRM license fetches keep their security headers intact. urls filter limits to http/https so file:// loads of the bundled frontend are untouched. - session.defaultSession.webRequest.onHeadersReceived( - // Electron's webRequest type name for iframes is 'subFrame' (camelCase), not the Chrome-extension 'sub_frame' — passing the wrong name throws "Invalid type sub_frame" synchronously which becomes an unhandledRejection and prevents the app from booting. - { urls: ['http://*/*', 'https://*/*'], types: ['subFrame'] }, - (details, callback) => { - const headers = { ...(details.responseHeaders || {}) }; - for (const k of Object.keys(headers)) { - const lk = k.toLowerCase(); - if (lk === 'x-frame-options') { - delete headers[k]; - } else if (lk === 'content-security-policy' || lk === 'content-security-policy-report-only') { - const cleaned = (headers[k] || []) - .map((v) => v.split(';').filter((d) => !/^\s*frame-ancestors\b/i.test(d)).join(';').trim()) - .filter(Boolean); - if (cleaned.length) headers[k] = cleaned; else delete headers[k]; + // Strip X-Frame-Options and CSP frame-ancestors directives on iframe subframe loads so the Windows BrowserCard iframe fallback (used because tag commit segfaults on Chromium 144 + this Electron 40 CastLabs build) can render sites that normally refuse to be embedded. Scoped to types:['sub_frame'] so OAuth popups, the main app frame, deep-link redirects, and DRM license fetches keep their security headers intact. urls filter limits to http/https so file:// loads of the bundled frontend are untouched. + ses.webRequest.onHeadersReceived( + // Electron's webRequest type name for iframes is 'subFrame' (camelCase), not the Chrome-extension 'sub_frame' — passing the wrong name throws "Invalid type sub_frame" synchronously which becomes an unhandledRejection and prevents the app from booting. + { urls: ['http://*/*', 'https://*/*'], types: ['subFrame'] }, + (details, callback) => { + const headers = { ...(details.responseHeaders || {}) }; + for (const k of Object.keys(headers)) { + const lk = k.toLowerCase(); + if (lk === 'x-frame-options') { + delete headers[k]; + } else if (lk === 'content-security-policy' || lk === 'content-security-policy-report-only') { + const cleaned = (headers[k] || []) + .map((v) => v.split(';').filter((d) => !/^\s*frame-ancestors\b/i.test(d)).join(';').trim()) + .filter(Boolean); + if (cleaned.length) headers[k] = cleaned; else delete headers[k]; + } } - } - callback({ responseHeaders: headers }); - }, - ); + callback({ responseHeaders: headers }); + }, + ); + }; + configureBrowsingSession(session.defaultSession); + configureBrowsingSession(session.fromPartition(BROWSER_PARTITION)); // Read-only logging for DRM license requests — no modifying interceptors // so the network stack can set Content-Type and other headers normally. diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index e6e56722..1556a5f3 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -123,6 +123,9 @@ const chromeUserAgent = navigator.userAgent .replace(/\s*Electron\/\S+/, '') .replace(/\s*OpenSwarm\/\S+/, ''); +// Persistent partition so browser-card logins/cookies/localStorage outlive a reload or quit. MUST match BROWSER_PARTITION in electron/main.js, which configures permissions + iframe header-strip on this exact partition. +const BROWSER_PARTITION = 'persist:openswarm-browser'; + // Sync exposure set at preload boot; async API fallback for older builds. const webviewPreloadPath: string | undefined = isElectron ? ((window as any).__OPENSWARM_WEBVIEW_PRELOAD__ @@ -1136,6 +1139,7 @@ const BrowserCard: React.FC = ({ else webviewMap.current.delete(tab.id); }} data-tab-id={tab.id} + partition={BROWSER_PARTITION} src="about:blank" {...({ allowpopups: 'true' } as any) /* React drops boolean-valued unknown attrs, so string it stays; @types/react wrongly says boolean */} useragent={chromeUserAgent} From 97a859e504df020f4aa7bc6f7a5cb20955789c7e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 19:50:33 -0700 Subject: [PATCH 02/37] [eric] browser: Settings "Clear browsing data" wipes the browser partition only --- electron/main.js | 8 ++++ electron/preload.js | 2 + .../sections/general/DataPrivacySection.tsx | 48 +++++++++++++++++++ frontend/src/types/electron.d.ts | 1 + 4 files changed, 59 insertions(+) diff --git a/electron/main.js b/electron/main.js index f5242128..4e75dbd7 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2448,6 +2448,14 @@ ipcMain.handle('get-webview-preload-path', () => { return `file://${path.join(__dirname, 'webview-preload.js')}`; }); +// Wipe ONLY the browser-card partition (cookies/cache/localStorage/IndexedDB), never the app's defaultSession. Surfaced as Settings -> Data & Privacy -> Clear browsing data. +ipcMain.handle('browser:clear-data', async () => { + const ses = session.fromPartition(BROWSER_PARTITION); + await ses.clearStorageData(); + await ses.clearCache(); + return { ok: true }; +}); + ipcMain.handle('get-update-status', () => cachedUpdateStatus); // One-shot recovery info: if the crash-watchdog relaunched us, returns the diff --git a/electron/preload.js b/electron/preload.js index 9bb2010c..99647e74 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -57,6 +57,8 @@ contextBridge.exposeInMainWorld('openswarm', { getInstallState: () => ipcRenderer.invoke('get-install-state'), // Factory reset: wipes the data dir and relaunches. Never resolves on success (the app exits first). hardReset: () => ipcRenderer.invoke('hard-reset'), + // Clears cookies/cache/localStorage for the browser-card partition only (never the app's defaultSession). Logs you out of sites opened in browser cards. + clearBrowserData: () => ipcRenderer.invoke('browser:clear-data'), connectSlack: () => ipcRenderer.invoke('connect-slack'), sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId), cdpDetachClean: (wcId) => ipcRenderer.invoke('cdp-detach-clean', wcId), diff --git a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx index 0ee8b13f..1186b0d4 100644 --- a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx +++ b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx @@ -20,11 +20,15 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => const [busy, setBusy] = useState(false); const [eraseText, setEraseText] = useState(''); const [err, setErr] = useState(null); + const [clearOpen, setClearOpen] = useState(false); + const [clearedOk, setClearedOk] = useState(false); const closeAll = () => { if (busy) return; setResetOpen(false); setEraseOpen(false); + setClearOpen(false); + setClearedOk(false); setEraseText(''); setErr(null); }; @@ -59,6 +63,24 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => } }; + const doClearBrowser = async () => { + const api = window.openswarm; + if (!api?.clearBrowserData) { + setErr('This only works in the desktop app.'); + return; + } + setBusy(true); + setErr(null); + try { + await api.clearBrowserData(); + setBusy(false); + setClearedOk(true); + } catch { + setBusy(false); + setErr("Couldn't clear browsing data just now. Try again in a moment."); + } + }; + const dialogPaperSx = { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, @@ -100,6 +122,14 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => + + + Clear browsing data + Signs you out of sites opened in browser cards and clears their cookies, cache, and local storage. Your chats, apps, and settings stay. + + + + Erase all content and settings @@ -120,6 +150,24 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => + + + {clearedOk ? 'Browsing data cleared' : 'Clear browsing data?'} + {clearedOk ? 'Cookies, cache, and local storage for browser cards are gone. Reload a browser card to see it signed out.' : 'This signs you out of sites in browser cards and clears their cookies, cache, and local storage. Your chats, apps, and settings stay.'} + {err && {err}} + + {clearedOk ? ( + + ) : ( + <> + + + + )} + + + + Erase all content and settings? diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 2f416e7b..e129804e 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -49,6 +49,7 @@ declare global { onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; openExternal: (url: string) => Promise; hardReset?: () => Promise; + clearBrowserData?: () => Promise<{ ok: boolean }>; onAuthUrl?: (cb: (url: string) => void) => () => void; onOauthClaim?: (cb: (url: string) => void) => () => void; } From ccd0bc39714b7ac468229a891c4ada6ae07ef201 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 20:45:18 -0700 Subject: [PATCH 03/37] [eric] browser: Ctrl/Cmd+R reloads the last-interacted browser, else the app --- electron/main.js | 12 +++++++++ electron/preload.js | 7 ++++++ .../src/app/components/Layout/AppShell.tsx | 25 +++++++++++++++++++ .../app/pages/Dashboard/cards/BrowserCard.tsx | 4 +++ frontend/src/shared/browserFocus.ts | 17 +++++++++++++ frontend/src/types/electron.d.ts | 1 + 6 files changed, 66 insertions(+) create mode 100644 frontend/src/shared/browserFocus.ts diff --git a/electron/main.js b/electron/main.js index 4e75dbd7..fb3e3d00 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1959,6 +1959,17 @@ function swallowCloseWindowShortcut(event, input) { } } +// Cmd/Ctrl+R: the default menu's Reload accelerator reloads the WHOLE app even when a browser webview is focused (the "Ctrl+R reloads OpenSwarm, not the browser" complaint). preventDefault kills that accelerator (same electron#19279 path as Cmd+W, dispatched against whichever webContents is focused, hence both main window AND guests); the renderer then reloads the last-interacted browser, or the app if none. Shift+R (force reload) is left alone. +function routeReloadShortcut(event, input) { + if (input.type !== 'keyDown') return; + if (!(input.meta || input.control) || input.shift || input.alt) return; + if ((input.key || '').toLowerCase() !== 'r') return; + event.preventDefault(); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:reload-shortcut'); + } catch (_) {} +} + app.on('web-contents-created', (_event, contents) => { // Block Cmd+W from closing the main window, whether the window chrome or one of // its embedded webviews has focus. OAuth popups (their own 'window' contents, @@ -1966,6 +1977,7 @@ app.on('web-contents-created', (_event, contents) => { // still Cmd+W them shut. if (isCreatingMainWindow || contents.getType() === 'webview') { contents.on('before-input-event', swallowCloseWindowShortcut); + contents.on('before-input-event', routeReloadShortcut); } // Override the user-agent on popup BrowserWindows (i.e. anything created diff --git a/electron/preload.js b/electron/preload.js index 99647e74..ae70b4bd 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -108,6 +108,13 @@ contextBridge.exposeInMainWorld('openswarm', { return () => ipcRenderer.removeListener('webview-new-window', listener); }, + // Cmd/Ctrl+R, intercepted in main (kills the default-menu reload), so the renderer can reload the focused browser instead of the whole app. + onReloadShortcut: (cb) => { + const listener = () => cb(); + ipcRenderer.on('openswarm:reload-shortcut', listener); + return () => ipcRenderer.removeListener('openswarm:reload-shortcut', listener); + }, + // Deep-link callback: fires when the OS opens the app with an // openswarm://auth?token=... URL (after Stripe-hosted checkout). onAuthUrl: (cb) => { diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 8e02cf0b..b72a807d 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { openSettingsModal } from '@/shared/state/settingsSlice'; +import { getLastInteractedBrowser, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus'; +import { getWebview } from '@/shared/browserRegistry'; import Box from '@mui/material/Box'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; @@ -323,6 +325,29 @@ const AppShell: React.FC = () => { }); }, [openUrlInBrowser]); + // Track the browser card the user last touched. Chrome clicks land on this document; a webview PAGE click can't reach it, so BrowserCard reports those via the app-clicked IPC. Clearing on any non-browser-card click is what makes Ctrl+R fall back to reloading the app. + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const card = (e.target as HTMLElement | null)?.closest?.('[data-select-type="browser-card"]') as HTMLElement | null; + if (card) setLastInteractedBrowser(card.getAttribute('data-select-id') || ''); + else clearLastInteractedBrowser(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, []); + + // Cmd/Ctrl+R: main neutralizes the default-menu reload (which would always reload the whole app) and hands us the decision. Reload the browser you last interacted with; if that wasn't a live browser, reload the app, exactly as before. + useEffect(() => { + const w = window as any; + if (!w.openswarm?.onReloadShortcut) return; + return w.openswarm.onReloadShortcut(() => { + const id = getLastInteractedBrowser(); + const wv = id ? getWebview(id) : undefined; + if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; fall through to app reload */ } } + window.location.reload(); + }); + }, []); + useEffect(() => { try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {} }, [sidebarWidth]); diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 1556a5f3..14effe16 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -46,6 +46,7 @@ import { setActiveTab as setRegistryActiveTab, type BrowserWebview, } from '@/shared/browserRegistry'; +import { setLastInteractedBrowser } from '@/shared/browserFocus'; import { useBrowserActivity } from '@/shared/useBrowserActivity'; import { getActionLabel } from '@/shared/browserCommandHandler'; import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl'; @@ -325,6 +326,9 @@ const BrowserCard: React.FC = ({ }, }), ); + } else if (e?.channel === 'app-clicked') { + // First in-guest mousedown: a page click never reaches the host document, so this IPC is how a webview-content click marks this browser as last-interacted (drives Ctrl+R/zoom/tab targeting). + setLastInteractedBrowser(browserId); } }; diff --git a/frontend/src/shared/browserFocus.ts b/frontend/src/shared/browserFocus.ts new file mode 100644 index 00000000..4cd06a67 --- /dev/null +++ b/frontend/src/shared/browserFocus.ts @@ -0,0 +1,17 @@ +// 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. +let lastInteractedBrowserId: string | null = null; + +export function setLastInteractedBrowser(browserId: string): void { + lastInteractedBrowserId = browserId; +} + +export function clearLastInteractedBrowser(): void { + lastInteractedBrowserId = null; +} + +export function getLastInteractedBrowser(): string | null { + return lastInteractedBrowserId; +} diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index e129804e..8d8ba8cb 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -47,6 +47,7 @@ declare global { onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void; onUpdateError: (cb: (message: string) => void) => () => void; onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; + onReloadShortcut?: (cb: () => void) => () => void; openExternal: (url: string) => Promise; hardReset?: () => Promise; clearBrowserData?: () => Promise<{ ok: boolean }>; From 4ef03b6508c57ddaeb8dfaa4189b9d1b9e619657 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 23:05:19 -0700 Subject: [PATCH 04/37] [eric] dashboard: quiesce + serialize app-card delete teardown to stop GPU-death crash --- .../Dashboard/cards/DashboardViewCard.tsx | 8 +++--- .../interaction/useDashboardShortcuts.ts | 10 ++++--- .../hooks/lifecycle/useDashboardLifecycle.ts | 16 ++++++++---- frontend/src/app/pages/Views/ViewPreview.tsx | 13 ++++++++++ frontend/src/shared/viewTeardown.ts | 26 +++++++++++++++++++ frontend/src/shared/viewWebviewRegistry.ts | 18 +++++++++++++ 6 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 frontend/src/shared/viewTeardown.ts create mode 100644 frontend/src/shared/viewWebviewRegistry.ts diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 784310d0..25db7d4e 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -10,7 +10,8 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt'; import CloseIcon from '@mui/icons-material/Close'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; -import { setViewCardPosition, setViewCardSize, removeViewCard, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice'; +import { setViewCardPosition, setViewCardSize, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice'; +import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { API_BASE, getAuthToken } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -303,7 +304,7 @@ const DashboardViewCard: React.FC = ({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); - dispatch(removeViewCard(output.id)); + void removeViewCardCleanly(output.id, dispatch); }; const handleRefresh = (e: React.MouseEvent) => { @@ -686,7 +687,7 @@ const DashboardOutputPreview: React.FC<{ This app's files are missing. dispatch(removeViewCard(output.id))} + onClick={() => void removeViewCardCleanly(output.id, dispatch)} sx={{ color: tokens.accent.primary, fontSize: '0.85rem', @@ -713,6 +714,7 @@ const DashboardOutputPreview: React.FC<{ return ( ; @@ -27,7 +28,7 @@ export function useDashboardShortcuts({ const dispatch = useAppDispatch(); useEffect(() => { - const parts = newAgentShortcut.toLowerCase().split('+'); + const parts = (newAgentShortcut || '').toLowerCase().split('+'); const key = parts[parts.length - 1]; const needsMeta = parts.includes('meta'); const needsCtrl = parts.includes('ctrl'); @@ -72,11 +73,12 @@ export function useDashboardShortcuts({ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; if (selection.selectedIds.size === 0) return; e.preventDefault(); + const viewIds: string[] = []; for (const [id, type] of selection.selectedIds) { if (type === 'agent') { dispatch(closeSession({ sessionId: id })); } else if (type === 'view') { - dispatch(removeViewCard(id)); + viewIds.push(id); } else if (type === 'browser') { removeBrowserCardCleanly(id, dispatch); } else if (type === 'note') { @@ -88,6 +90,8 @@ export function useDashboardShortcuts({ dispatch(closeWorkflowsHub()); } } + // Tear view cards down ONE AT A TIME (each quiesces its GPU surface first); ripping several large app webviews out in one frame is what piles up "non-existent mailbox" errors and kills the GPU process. + void (async () => { for (const id of viewIds) await removeViewCardCleanly(id, dispatch); })(); selection.deselectAll(); }; window.addEventListener('keydown', handleDelete); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index d615c2c0..b9176b24 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -14,13 +14,13 @@ import { addBrowserCard, addViewCard, resetLayout, - removeViewCard, clearPendingFocusBrowserId, clearPendingFocusWorkflowId, clearPendingFocusWorkflowsHub, type ViewCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; +import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice'; import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; @@ -280,11 +280,17 @@ export function useDashboardLifecycle({ }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); // Prune orphan view cards whose underlying output was deleted (e.g. via the Views page). Without this, the layout entry persists in the minimap and contentBounds even though DashboardViewCard renders nothing. Gated on outputsRefetched (THIS open's fresh fetch), NOT the sticky global outputsLoaded: on a freshly-imported dashboard the global flag is already true from a prior dashboard, so the old gate pruned the just-imported app card against a stale apps list and the debounced save persisted the wipe. + const pruningRef = useRef(false); useEffect(() => { - if (!layoutInitialized || !outputsRefetched) return; - for (const outputId of Object.keys(viewCards)) { - if (!outputs[outputId]) dispatch(removeViewCard(outputId)); - } + if (!layoutInitialized || !outputsRefetched || pruningRef.current) return; + const orphans = Object.keys(viewCards).filter((outputId) => !outputs[outputId]); + if (!orphans.length) return; + pruningRef.current = true; + // Serialize the prune (one quiesce at a time) so deleting a couple of large apps via the Views page can't rip several live webview GPU surfaces out in one frame; the ref stops this effect's own dispatches from spawning overlapping loops. + void (async () => { + try { for (const outputId of orphans) await removeViewCardCleanly(outputId, dispatch); } + finally { pruningRef.current = false; } + })(); }, [layoutInitialized, outputsRefetched, viewCards, outputs, dispatch]); // On first load after outputs settle, snapshot every existing Output id as "already accounted for." Any output that ARRIVES later (typically the agent:output_upserted WS broadcast the backend fires the instant a view-builder session is seeded, at session start) whose session_id points at a view-builder chat on this dashboard gets a view card dropped on the canvas right away. Per-mount tracked so a manual close after auto-open stays closed. Prior approach keyed off a pending-set populated inside launchAndSendFirstMessage.then(): the WS upsert won the race and the effect saw an empty set, so the card didn't pop until the session-end meta-sync re-broadcast. diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index a62212ff..a778aa05 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -6,6 +6,7 @@ import { useElementSelection } from '@/app/components/editor/ElementSelectionCon import { useIframeElementSelector } from './useIframeElementSelector'; import { getAuthToken, ensureAuthToken } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { registerViewWebview, unregisterViewWebview, type ViewWebview } from '@/shared/viewWebviewRegistry'; // In Electron use to escape iframe restrictions (popups, mic/camera, WebAuthn, cookied fetch); outside Electron fall back to iframe. const isElectron = navigator.userAgent.includes('Electron'); @@ -57,6 +58,8 @@ interface Props { interactive?: boolean; /** Fired when the preload reports a mousedown inside the guest, so the host can flip the card into interactive mode. */ onAppClicked?: () => void; + /** Dashboard card's output id. When set, the live webview registers under it so the delete path can quiesce its GPU surface before unmount. Omitted in the App Builder (no card teardown). */ + registryId?: string; } function buildSrcdoc( @@ -96,6 +99,7 @@ const ViewPreview = forwardRef(({ onContentLoad, interactive = false, onAppClicked, + registryId, }, ref) => { const iframeRef = useRef(null); const webviewRef = useRef(null); @@ -276,6 +280,15 @@ const ViewPreview = forwardRef(({ }; }, [useWebview, onConsoleMessage, onAppClicked, iframeSrc]); + // Register the live webview so the dashboard delete path can quiesce its GPU surface before unmount; unregister on teardown so a stale handle never gets navigated. + useEffect(() => { + if (!useWebview || !registryId) return; + const wv = webviewRef.current; + if (!wv) return; + registerViewWebview(registryId, wv as ViewWebview); + return () => unregisterViewWebview(registryId); + }, [useWebview, registryId, iframeSrc]); + // Mirror `interactive` into a ref so the once-per-load did-finish-load listener can read the latest value when it pushes initial state. const interactiveRef = useRef(interactive); interactiveRef.current = interactive; diff --git a/frontend/src/shared/viewTeardown.ts b/frontend/src/shared/viewTeardown.ts new file mode 100644 index 00000000..7c282033 --- /dev/null +++ b/frontend/src/shared/viewTeardown.ts @@ -0,0 +1,26 @@ +import type { Dispatch } from '@reduxjs/toolkit'; +import { removeViewCard } from '@/shared/state/dashboardLayoutSlice'; +import { getViewWebview } from '@/shared/viewWebviewRegistry'; + +// A wedged app must never hold a card open; cap the whole quiesce so delete stays responsive. Common case (about:blank is a trivial nav) resolves in well under this. +const QUIESCE_BUDGET_MS = 250; + +// Navigate a doomed card's webview to about:blank so the running app's heavy GPU surfaces are released BEFORE React destroys the , leaving only a trivial surface to tear down. Bounded + fail-open. +export async function quiesceViewWebview(outputId: string): Promise { + const wv = getViewWebview(outputId); + if (!wv) return; + try { + await Promise.race([ + wv.loadURL('about:blank').catch(() => {}), + new Promise((resolve) => setTimeout(resolve, QUIESCE_BUDGET_MS)), + ]); + } catch { + // webview already torn down; nothing to quiesce + } +} + +// Quiesce a card's live preview surface, THEN remove it. Every view-card delete path routes through here so none rips a live GPU surface out mid-composite. Awaited in a loop (multi-select Delete, orphan prune) the teardowns SERIALIZE, which is what stops the simultaneous "non-existent mailbox" pile-up that kills the GPU process. +export async function removeViewCardCleanly(outputId: string, dispatch: Dispatch): Promise { + await quiesceViewWebview(outputId); + dispatch(removeViewCard(outputId)); +} diff --git a/frontend/src/shared/viewWebviewRegistry.ts b/frontend/src/shared/viewWebviewRegistry.ts new file mode 100644 index 00000000..2f6d0e23 --- /dev/null +++ b/frontend/src/shared/viewWebviewRegistry.ts @@ -0,0 +1,18 @@ +// Live app-card preview webviews keyed by output id. The delete path looks a card's up here to quiesce its GPU surface BEFORE React rips the element out; without it, deleting a couple of large app cards at once tears down several live SharedImage surfaces in one frame, which piles up "non-existent mailbox" errors and kills the GPU process (taking the whole app down with no dump). Mirror of browserRegistry, for the non-CDP preview webviews. +export interface ViewWebview extends HTMLElement { + loadURL: (url: string) => Promise; +} + +const registry = new Map(); + +export function registerViewWebview(outputId: string, wv: ViewWebview): void { + registry.set(outputId, wv); +} + +export function unregisterViewWebview(outputId: string): void { + registry.delete(outputId); +} + +export function getViewWebview(outputId: string): ViewWebview | undefined { + return registry.get(outputId); +} From bdf576126df485a708b020662f56a573ef8744f4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 23:09:21 -0700 Subject: [PATCH 05/37] [eric] apps: link node_modules on import so imported backend apps boot first time, no restart --- backend/apps/outputs/view_builder_templates.py | 4 ++-- backend/apps/swarm/entities/apps.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 24363fa2..0a357a60 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -374,7 +374,7 @@ def p_try_link_dir(src: str, target: str) -> bool: return False -def p_link_node_modules(workspace_dir: str) -> None: +def link_node_modules(workspace_dir: str) -> None: """After copytree, point the workspace's frontend/node_modules at the warm-cache directory. Safe fallback; if the cache isn't ready, the workspace's run.sh will fall through to its own install path.""" @@ -570,7 +570,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No dirs_exist_ok=True, ) # Symlink the workspace's frontend/node_modules at the warm cache so `npm install` can be skipped entirely by the workspace run.sh. - p_link_node_modules(workspace_dir) + link_node_modules(workspace_dir) env_path = os.path.join(workspace_dir, ".env") env_example_path = os.path.join(workspace_dir, ".env.example") src_example = os.path.join(WEBAPP_TEMPLATE_DIR, ".env.example") diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py index 21c09357..37cbb0ad 100644 --- a/backend/apps/swarm/entities/apps.py +++ b/backend/apps/swarm/entities/apps.py @@ -146,6 +146,7 @@ def p_localize_env(folder: str) -> None: from backend.apps.outputs.view_builder_templates import ( DEBUGGER_PATH, TEMPLATE_BACKEND_PATH, + link_node_modules, patch_env_port, warm_venv_dir, ) @@ -158,3 +159,8 @@ def p_localize_env(folder: str) -> None: patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir()) except Exception: pass + # Imported apps arrive WITHOUT node_modules (export drops the warm-cache symlink), so relink it here like seed does; without it the first runtime boot npm-installs while the preview races onto a not-yet-bound port, so the app stays blank until a full restart. + try: + link_node_modules(folder) + except Exception: + pass From 598af2303eca83b92b2ec149f069ebcd4cc140c9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 23:10:41 -0700 Subject: [PATCH 06/37] [eric] frontend: fill missing settings fields from defaults + guard new_agent_shortcut crash --- frontend/src/shared/state/settingsSlice.ts | 51 ++++++++++++---------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index cc8254d6..ea12d876 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -114,25 +114,28 @@ interface SettingsState { freeTrialArmSettled: boolean; } +/** Baseline for every required settings field. Spread under any backend payload so an older saved shape that predates a newer field (e.g. new_agent_shortcut) can't surface as undefined and crash a consumer. */ +export const DEFAULT_SETTINGS: AppSettings = { + default_system_prompt: DEFAULT_SYSTEM_PROMPT, + default_folder: null, + default_model: 'sonnet', + default_mode: 'agent', + default_max_turns: null, + default_thinking_level: 'auto', + zoom_sensitivity: 50, + theme: 'dark', + new_agent_shortcut: 'Meta+l', + anthropic_api_key: null, + browser_homepage: 'https://duckduckgo.com', + auto_select_mode_on_new_agent: false, + expand_new_chats_in_dashboard: true, + auto_reveal_sub_agents: true, + dev_mode: false, + allow_experimental_updates: false, +}; + const initialState: SettingsState = { - data: { - default_system_prompt: DEFAULT_SYSTEM_PROMPT, - default_folder: null, - default_model: 'sonnet', - default_mode: 'agent', - default_max_turns: null, - default_thinking_level: 'auto', - zoom_sensitivity: 50, - theme: 'dark', - new_agent_shortcut: 'Meta+l', - anthropic_api_key: null, - browser_homepage: 'https://duckduckgo.com', - auto_select_mode_on_new_agent: false, - expand_new_chats_in_dashboard: true, - auto_reveal_sub_agents: true, - dev_mode: false, - allow_experimental_updates: false, - }, + data: DEFAULT_SETTINGS, loading: false, loaded: false, modalOpen: false, @@ -289,11 +292,13 @@ const settingsSlice = createSlice({ state.loaded = true; // Drop a stale response: on boot three fetches race (initial, sub-sync, free-trial mint); if the pre-mint one resolves last it would wipe the armed trial. Newest wins. if (state.latestWriteId && action.meta.requestId !== state.latestWriteId) return; + // Fill any field an older backend shape omitted so no consumer reads undefined; the payload still wins for everything it does send. + const merged = { ...DEFAULT_SETTINGS, ...action.payload }; // Skip ref-assignment when byte-identical; keeps background refetch polls from re-firing every effect. - const next = JSON.stringify(action.payload); + const next = JSON.stringify(merged); const prev = JSON.stringify(state.data); if (next !== prev) { - state.data = action.payload; + state.data = merged; } }) .addCase(fetchSettings.rejected, (state) => { @@ -303,19 +308,19 @@ const settingsSlice = createSlice({ .addCase(updateSettingsPatch.fulfilled, (state, action) => { // A user save is authoritative; claim newest so an in-flight GET can't overwrite it, and consume the draft so reopening shows the saved state. state.latestWriteId = action.meta.requestId; - state.data = action.payload; + state.data = { ...DEFAULT_SETTINGS, ...action.payload }; state.draft = null; state.draftTab = null; }) .addCase(resetSystemPrompt.fulfilled, (state, action) => { state.latestWriteId = action.meta.requestId; - state.data = action.payload; + state.data = { ...DEFAULT_SETTINGS, ...action.payload }; state.draft = null; state.draftTab = null; }) .addCase(dismissMcpSuggestion.fulfilled, (state, action) => { state.latestWriteId = action.meta.requestId; - state.data = action.payload; + state.data = { ...DEFAULT_SETTINGS, ...action.payload }; }); }, }); From ed40d9dc72567244a8653ae4e976cf91be37da77 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 23:10:41 -0700 Subject: [PATCH 07/37] [eric] agents: backfill pending_approvals + validate disk browser-agent children (undefined .some() crash) --- backend/apps/agents/manager/session/SessionLifecycle.py | 8 +++++++- frontend/src/shared/state/agentsSlice.ts | 9 +++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index 0cc905a3..b48b776a 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -250,7 +250,13 @@ class SessionLifecycle(AgentManagerProtocol): if sid in seen: continue if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id: - results.append(data) + # Validate + model_dump like the in-memory branch above; a raw legacy dict that predates a field (e.g. pending_approvals) would ship half-shaped and crash the renderer. + try: + sess = AgentSession(**data) + except Exception: + logger.warning(f"get_browser_agent_children: skipping unloadable session {sid}", exc_info=True) + continue + results.append(sess.model_dump(mode="json")) return results diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 4d586354..3d680e0f 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -1143,7 +1143,7 @@ const agentsSlice = createSlice({ state.loading = false; }) .addCase(launchAgent.fulfilled, (state, action) => { - state.sessions[action.payload.id] = { ...action.payload, name: normalizeSessionName(action.payload.name), tool_group_meta: action.payload.tool_group_meta ?? {} }; + state.sessions[action.payload.id] = { ...action.payload, name: normalizeSessionName(action.payload.name), tool_group_meta: action.payload.tool_group_meta ?? {}, pending_approvals: action.payload.pending_approvals ?? [] }; state.activeSessionId = action.payload.id; if (!state.expandedSessionIds.includes(action.payload.id)) { state.expandedSessionIds.push(action.payload.id); @@ -1156,7 +1156,7 @@ const agentsSlice = createSlice({ const { draftId, session } = action.payload; const shouldExpand = action.meta.arg.expand !== false; delete state.sessions[draftId]; - state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {} }; + state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, pending_approvals: session.pending_approvals ?? [] }; state.activeSessionId = session.id; state.draftLaunchMap[draftId] = session.id; state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id)); @@ -1242,7 +1242,7 @@ const agentsSlice = createSlice({ }) .addCase(duplicateSession.fulfilled, (state, action) => { const session = action.payload; - state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name) }; + state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), pending_approvals: session.pending_approvals ?? [] }; }) .addCase(closeSession.fulfilled, (state, action) => { const sessionId = action.payload; @@ -1309,7 +1309,7 @@ const agentsSlice = createSlice({ }) .addCase(resumeSession.fulfilled, (state, action) => { const session = action.payload; - state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {} }; + state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, pending_approvals: session.pending_approvals ?? [] }; delete state.history[session.id]; state.activeSessionId = session.id; if (!state.expandedSessionIds.includes(session.id)) { @@ -1381,6 +1381,7 @@ const agentsSlice = createSlice({ ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, + pending_approvals: session.pending_approvals ?? [], }; } } From 8cb781c01c16c9664d6a19049fe84f74bbaac86b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 23:21:44 -0700 Subject: [PATCH 08/37] [eric] apps: only link warm node_modules on import when package.json matches template (custom-dep apps install their own) --- backend/apps/outputs/view_builder_templates.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 0a357a60..0ec6cce5 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -381,6 +381,14 @@ def link_node_modules(workspace_dir: str) -> None: cache_modules = ensure_warm_cache() if not cache_modules: return + # The warm cache holds the TEMPLATE's deps; only link it when this workspace's package.json matches, else run.sh sees vite present, skips install, and the app's custom deps are missing. On mismatch (a customized import) leave node_modules absent so run.sh installs the app's real deps. + pkg_path = os.path.join(workspace_dir, "frontend", "package.json") + try: + with open(pkg_path, "rb") as fh: + if hashlib.sha256(fh.read()).hexdigest()[:12] != warm_cache_digest(): + return + except OSError: + return target = os.path.join(workspace_dir, "frontend", "node_modules") if os.path.islink(target): try: From 215ddb45c0abc10d4ab5a9a534aceda1243a7cb3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 23:40:32 -0700 Subject: [PATCH 09/37] [eric] browser: strip openswarm/Electron from webview UA (case-insensitive; was leaking openswarm/1.5.1 to every site) --- frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 14effe16..378905b6 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -121,8 +121,8 @@ const isWindows = navigator.userAgent.includes('Windows'); const isElectron = navigator.userAgent.includes('Electron') && (!isWindows || windowsWebviewEnabled()); const chromeUserAgent = navigator.userAgent - .replace(/\s*Electron\/\S+/, '') - .replace(/\s*OpenSwarm\/\S+/, ''); + .replace(/\s*Electron\/\S+/i, '') + .replace(/\s*openswarm\/\S+/i, ''); // Persistent partition so browser-card logins/cookies/localStorage outlive a reload or quit. MUST match BROWSER_PARTITION in electron/main.js, which configures permissions + iframe header-strip on this exact partition. const BROWSER_PARTITION = 'persist:openswarm-browser'; From f30597b7c83788b2e326148208a63dd18194f027 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 00:04:57 -0700 Subject: [PATCH 10/37] [eric] browser: Cmd+R outside a browser is a no-op, not an app reload that wipes card sessions --- frontend/src/app/components/Layout/AppShell.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index b72a807d..ed62f330 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -336,15 +336,14 @@ const AppShell: React.FC = () => { return () => document.removeEventListener('pointerdown', onPointerDown, true); }, []); - // Cmd/Ctrl+R: main neutralizes the default-menu reload (which would always reload the whole app) and hands us the decision. Reload the browser you last interacted with; if that wasn't a live browser, reload the app, exactly as before. + // Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. Reload the browser you last interacted with; if your last click was NOT in a live browser, do nothing, a real browser reloads the active tab, not the whole app shell, and reloading the renderer here would destroy every browser card's webContents and wipe its sessionStorage (silently logging you out of sites like Discord). View > Reload still reloads the app on purpose. useEffect(() => { const w = window as any; if (!w.openswarm?.onReloadShortcut) return; return w.openswarm.onReloadShortcut(() => { const id = getLastInteractedBrowser(); const wv = id ? getWebview(id) : undefined; - if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; fall through to app reload */ } } - window.location.reload(); + if (wv) { try { wv.reload(); } catch (_e) { /* torn-down webview; ignore */ } } }); }, []); From ef03ea71bbb3cd2cffee95f34edc91b897647e61 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 01:04:59 -0700 Subject: [PATCH 11/37] [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); From 71e9a6267189fb53816a93394cdf38c914b811b3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 02:01:17 -0700 Subject: [PATCH 12/37] [eric] backend: connect OAuth reads pending_oauth from oauth_state, not missing backend.main.p_pending_oauth --- backend/apps/agents/agents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 62ea20ce..0a398d52 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -401,8 +401,8 @@ async def subscriptions_connect(body: dict): result = await start_oauth(provider) if result.get("flow") == "authorization_code" and result.get("state"): - from backend.main import p_pending_oauth - p_pending_oauth[result["state"]] = { + from backend.apps.oauth_state import pending_oauth + pending_oauth[result["state"]] = { "provider": provider, "code_verifier": result.get("code_verifier", ""), "redirect_uri": result.get("redirect_uri", ""), From 959ffb0b3914ee511b075a477789a83fe08f7f42 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 02:04:44 -0700 Subject: [PATCH 13/37] [eric] browser: park keep-alive cards off-screen so they don't bleed onto other dashboards --- frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 07f194d0..e433ffea 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -740,14 +740,13 @@ 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, + // Kept-alive card from another dashboard: parked far off-screen because a guest surface ignores CSS visibility/opacity; click-through, webContents stays live so its session survives, never unmounted. 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. willChange: 'transform', - left: displayX, + left: keepAliveHidden ? -100000 : displayX, top: displayY, width: displayW, height: displayH, From 02f119cdbc34669b192dad3aa0f6a781d40b62d1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 02:10:01 -0700 Subject: [PATCH 14/37] [eric] browser: Cmd+R reloads the browser you're in or last used in place (was a no-op), never an app wipe --- frontend/src/app/components/Layout/AppShell.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index ed62f330..f6773c47 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { openSettingsModal } from '@/shared/state/settingsSlice'; -import { getLastInteractedBrowser, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus'; +import { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus'; import { getWebview } from '@/shared/browserRegistry'; import Box from '@mui/material/Box'; import ListItemButton from '@mui/material/ListItemButton'; @@ -336,14 +336,16 @@ const AppShell: React.FC = () => { return () => document.removeEventListener('pointerdown', onPointerDown, true); }, []); - // Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. Reload the browser you last interacted with; if your last click was NOT in a live browser, do nothing, a real browser reloads the active tab, not the whole app shell, and reloading the renderer here would destroy every browser card's webContents and wipe its sessionStorage (silently logging you out of sites like Discord). View > Reload still reloads the app on purpose. + // Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. Reload the browser you're in or last used IN PLACE (keeps its login); only when no browser is open at all do we fall back to a full app reload, since reloading the renderer destroys every webview and wipes its session. useEffect(() => { const w = window as any; if (!w.openswarm?.onReloadShortcut) return; return w.openswarm.onReloadShortcut(() => { - const id = getLastInteractedBrowser(); - const wv = id ? getWebview(id) : undefined; - if (wv) { try { wv.reload(); } catch (_e) { /* torn-down webview; ignore */ } } + for (const id of [getLastInteractedBrowser(), ...getKeepAliveBrowserIds()]) { + const wv = id ? getWebview(id) : undefined; + if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; try the next */ } } + } + window.location.reload(); }); }, []); From 9d6abc7091848092d7d8d86e3b2f309da9c29639 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 02:24:56 -0700 Subject: [PATCH 15/37] [eric] browser: tag new browser cards with their home dashboard so they don't bleed onto every dashboard --- frontend/src/shared/hooks/useLastDashboardId.ts | 10 +++------- frontend/src/shared/lastDashboardId.ts | 13 +++++++++++++ frontend/src/shared/state/dashboardLayoutSlice.ts | 3 +++ 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 frontend/src/shared/lastDashboardId.ts diff --git a/frontend/src/shared/hooks/useLastDashboardId.ts b/frontend/src/shared/hooks/useLastDashboardId.ts index 6ee67f8d..da83eb63 100644 --- a/frontend/src/shared/hooks/useLastDashboardId.ts +++ b/frontend/src/shared/hooks/useLastDashboardId.ts @@ -1,8 +1,8 @@ import { useEffect, useState, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; +import { setLastDashboardId } from '@/shared/lastDashboardId'; const STORAGE_KEY = 'openswarm_last_dashboard_id'; -const WINDOW_KEY = '__openswarm_last_dashboard_id'; /** Sticky last-visited dashboard id so Dashboard stays mounted across non-dashboard nav. */ export function useLastDashboardId(): [string | null, (id: string | null) => void] { @@ -23,7 +23,7 @@ export function useLastDashboardId(): [string | null, (id: string | null) => voi try { localStorage.setItem(STORAGE_KEY, match[1]); } catch {} - (window as any)[WINDOW_KEY] = match[1]; + setLastDashboardId(match[1]); } }, [location.pathname, lastId]); @@ -36,11 +36,7 @@ export function useLastDashboardId(): [string | null, (id: string | null) => voi localStorage.removeItem(STORAGE_KEY); } } catch {} - if (id) { - (window as any)[WINDOW_KEY] = id; - } else { - delete (window as any)[WINDOW_KEY]; - } + setLastDashboardId(id); }, []); return [lastId, setLastId]; diff --git a/frontend/src/shared/lastDashboardId.ts b/frontend/src/shared/lastDashboardId.ts new file mode 100644 index 00000000..59da2f46 --- /dev/null +++ b/frontend/src/shared/lastDashboardId.ts @@ -0,0 +1,13 @@ +// The dashboard the user is currently on, mirrored to a window global so low-level non-React code (the addBrowserCard reducer) can tag a new browser card with its home dashboard at birth, instead of the card leaking onto every dashboard until the first layout save. +const WINDOW_KEY = '__openswarm_last_dashboard_id'; + +export function setLastDashboardId(id: string | null): void { + if (typeof window === 'undefined') return; + if (id) (window as any)[WINDOW_KEY] = id; + else delete (window as any)[WINDOW_KEY]; +} + +export function getLastDashboardId(): string | null { + if (typeof window === 'undefined') return null; + return ((window as any)[WINDOW_KEY] as string) || null; +} diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 32323bc8..77661a7b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1,6 +1,7 @@ import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@reduxjs/toolkit'; import { launchAndSendFirstMessage } from './agentsSlice'; import { API_BASE } from '@/shared/config'; +import { getLastDashboardId } from '@/shared/lastDashboardId'; // fetchSession 404/410 strips the layout card to stop AgentChat remount-loop. Matched by string to avoid circular import. const fetchSessionRejectedAction = createAction< @@ -708,6 +709,8 @@ const dashboardLayoutSlice = createSlice({ width: DEFAULT_BROWSER_CARD_W, height: DEFAULT_BROWSER_CARD_H, zOrder: state.nextZOrder++, + // Born onto the current dashboard so it shows there and only there, never bleeding onto every dashboard while it waits for the first layout save to tag it. + dashboard_id: getLastDashboardId() ?? undefined, }; state.pendingFocusBrowserId = id; }, From 22649ba377c281bd46351a3a5009407e694fc978 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 03:00:44 -0700 Subject: [PATCH 16/37] [eric] browser: persist browser-card dashboard_id so cards keep their home and stop bleeding across dashboards --- backend/apps/dashboards/models.py | 2 ++ frontend/src/shared/state/dashboardLayoutSlice.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 926f4f28..5a6e7fa7 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -40,6 +40,8 @@ class BrowserCardPosition(BaseModel): spawned_by: Optional[str] = None # When the agent leaves the deliverable on the page (a video playing, a page to read), it sets this so the frontend's auto-close on parent finish skips the card and the browser stays put. keep_open: bool = False + # The dashboard this card calls home. Persisted so the home survives a save; without it the card reloads untagged and renders on EVERY dashboard (the cross-dashboard bleed). + dashboard_id: Optional[str] = None class NotePosition(BaseModel): diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 77661a7b..7c9afecf 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1344,8 +1344,9 @@ const dashboardLayoutSlice = createSlice({ // 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; + // Default a missing home to the dashboard we're loading (legacy/untagged cards), but DON'T overwrite a real persisted home: a card saved here yet owned elsewhere is leftover from the old untagged-shows-everywhere bug, leaving its true home lets it park off-screen and get cleaned on the next save instead of bleeding. for (const card of Object.values(incoming)) { - card.dashboard_id = ownerDashboardId; + if (!card.dashboard_id) 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)) { From 80a8b6a94ffb4e314009e5ca8867a1ecebd0ded9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 03:15:30 -0700 Subject: [PATCH 17/37] [eric] browser: Cmd+R reloads focused browser, else the app (MRU fallback was blocking app reload) --- frontend/src/app/components/Layout/AppShell.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index f6773c47..81e9ae52 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { openSettingsModal } from '@/shared/state/settingsSlice'; -import { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus'; +import { getLastInteractedBrowser, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus'; import { getWebview } from '@/shared/browserRegistry'; import Box from '@mui/material/Box'; import ListItemButton from '@mui/material/ListItemButton'; @@ -336,15 +336,14 @@ const AppShell: React.FC = () => { return () => document.removeEventListener('pointerdown', onPointerDown, true); }, []); - // Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. Reload the browser you're in or last used IN PLACE (keeps its login); only when no browser is open at all do we fall back to a full app reload, since reloading the renderer destroys every webview and wipes its session. + // Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. If you're focused IN a browser, reload just that one in place (keeps its login); the moment you click off into the dashboard it reloads OpenSwarm itself, same as a normal browser tab vs the app window. useEffect(() => { const w = window as any; if (!w.openswarm?.onReloadShortcut) return; return w.openswarm.onReloadShortcut(() => { - for (const id of [getLastInteractedBrowser(), ...getKeepAliveBrowserIds()]) { - const wv = id ? getWebview(id) : undefined; - if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; try the next */ } } - } + const id = getLastInteractedBrowser(); + const wv = id ? getWebview(id) : undefined; + if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; fall through to app reload */ } } window.location.reload(); }); }, []); From d0dd6ee32bb3bdefedc460a82a8a45880d040611 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 03:47:26 -0700 Subject: [PATCH 18/37] [eric] browser: present webview as Google Chrome in client hints + headers, not bare Chromium (anti-bot tell) --- electron/main.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/electron/main.js b/electron/main.js index fb3e3d00..4b04e562 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1767,6 +1767,26 @@ app.whenReady().then(async () => { configureBrowsingSession(session.defaultSession); configureBrowsingSession(session.fromPartition(BROWSER_PARTITION)); + // Add a "Google Chrome" brand to the browser partition's sec-ch-ua request hints so they match the navigator.userAgentData patch injected on dom-ready and the spoofed Chrome UA string; a Chrome UA paired with Chromium-only hints is the embedded-app tell aggressive anti-bot (Cloudflare) flags on a real human. Scoped to the browser partition, the app's own file:// + localhost traffic is untouched. + const addGoogleChromeBrand = (value) => { + if (typeof value !== 'string' || value.includes('"Google Chrome"')) return value; + const m = value.match(/"Chromium";v="([^"]+)"/); + return m ? `${value}, "Google Chrome";v="${m[1]}"` : value; + }; + session.fromPartition(BROWSER_PARTITION).webRequest.onBeforeSendHeaders( + { urls: ['http://*/*', 'https://*/*'] }, + (details, callback) => { + const headers = { ...(details.requestHeaders || {}) }; + for (const k of Object.keys(headers)) { + const lk = k.toLowerCase(); + if (lk === 'sec-ch-ua' || lk === 'sec-ch-ua-full-version-list') { + headers[k] = addGoogleChromeBrand(headers[k]); + } + } + callback({ requestHeaders: headers }); + }, + ); + // Read-only logging for DRM license requests — no modifying interceptors // so the network stack can set Content-Type and other headers normally. session.defaultSession.webRequest.onSendHeaders( @@ -2163,6 +2183,32 @@ app.on('web-contents-created', (_event, contents) => { try { contents.reload(); } catch { /* nothing more we can do from here */ } }); + // Match navigator.userAgentData to the spoofed Chrome UA + the browser-partition sec-ch-ua header rewrite so the page world agrees with the headers; contextIsolation hides the preload, so this page-world patch is injected here. A Chrome UA with Chromium-only hints is the embedded-app tell that aggressive anti-bot (Cloudflare) flags on a real human. + contents.on('dom-ready', () => { + contents.executeJavaScript(` + (function(){ + try { + var orig = navigator.userAgentData; + if (!orig || !Array.isArray(orig.brands) || orig.brands.some(function(b){ return b.brand === 'Google Chrome'; })) return; + var addChrome = function(list){ + if (!Array.isArray(list) || list.some(function(b){ return b.brand === 'Google Chrome'; })) return list; + var ch = list.find(function(b){ return b.brand === 'Chromium'; }); + return ch ? list.concat([{ brand: 'Google Chrome', version: ch.version }]) : list; + }; + var brands = addChrome(orig.brands); + var patched = { + brands: brands, + mobile: orig.mobile, + platform: orig.platform, + getHighEntropyValues: function(h){ return orig.getHighEntropyValues(h).then(function(v){ if (v && Array.isArray(v.fullVersionList)) v.fullVersionList = addChrome(v.fullVersionList); return v; }); }, + toJSON: function(){ return { brands: brands, mobile: orig.mobile, platform: orig.platform }; }, + }; + Object.defineProperty(navigator, 'userAgentData', { get: function(){ return patched; }, configurable: true }); + } catch (e) {} + })(); + `).catch(() => {}); + }); + // WebAuthn/passkey shim. Injected on every dom-ready in the main world // via executeJavaScript (which uses V8's direct evaluation path and // bypasses Trusted Types CSP — inline