mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[eric] merge eric/browser-parity: in-app browser parity (persistence, focus keystone, zoom/find/tab/middle-click, reopen-closed)
# Conflicts: # backend/apps/outputs/workspace_io.py
This commit is contained in:
@@ -2008,6 +2008,7 @@ async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id:
|
||||
width=1280,
|
||||
height=800,
|
||||
spawned_by=parent_session_id,
|
||||
dashboard_id=dashboard_id,
|
||||
)
|
||||
dashboard.layout.browser_cards[browser_id] = card
|
||||
dashboard.updated_at = datetime.now()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -376,6 +376,8 @@ class AppRuntime:
|
||||
env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
|
||||
# Hand the workspace's backend/run.sh the exact interpreter we're running on. In the packaged build that's the bundled standalone Python, so a fresh machine with no system `python3` still works; in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already rides in via os.environ (set by the Electron shell) for run.sh's Node resolution.
|
||||
env["OPENSWARM_PYTHON"] = sys.executable
|
||||
# Force npm to skip dependency lifecycle scripts for every install run.sh triggers. An imported app's package.json is untrusted (it brings its own run.sh, so we can't gate the flag there); a malicious dep's postinstall would otherwise run arbitrary code on the host the moment its preview boots. Vite/esbuild get their platform binary via optionalDependencies, not a script, so this doesn't break the build.
|
||||
env["npm_config_ignore_scripts"] = "true"
|
||||
return env
|
||||
|
||||
async def stop(self) -> None:
|
||||
|
||||
@@ -23,18 +23,17 @@ def p_resolve_npm() -> list[str] | None:
|
||||
node_path = os.environ.get("OPENSWARM_NODE_PATH")
|
||||
if node_path and os.path.exists(node_path):
|
||||
node_dir = os.path.dirname(node_path)
|
||||
# Prefer invoking npm-cli.js through our bundled node so this doesn't depend on a system node for the shim's shebang. Second entry is the canonical Mac-dist layout (lib/node_modules/npm); first is the Windows layout (node_modules/npm beside node.exe).
|
||||
for cli in (
|
||||
os.path.join(node_dir, "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
os.path.join(os.path.dirname(node_dir), "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
):
|
||||
if os.path.exists(cli):
|
||||
return [node_path, cli]
|
||||
for shim in ("npm.cmd", "npm"):
|
||||
cand = os.path.join(node_dir, shim)
|
||||
if os.path.exists(cand):
|
||||
return [cand]
|
||||
# node.exe with no sibling npm: invoke npm-cli.js directly via node.
|
||||
for rel in (
|
||||
os.path.join("node_modules", "npm", "bin", "npm-cli.js"),
|
||||
os.path.join(node_dir, "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
):
|
||||
cli = rel if os.path.isabs(rel) else os.path.join(node_dir, rel)
|
||||
if os.path.exists(cli):
|
||||
return [node_path, cli]
|
||||
for name in ("npm.cmd", "npm") if sys.platform == "win32" else ("npm",):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
@@ -304,7 +303,7 @@ def ensure_warm_cache() -> str | None:
|
||||
tmpl_pkg = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json")
|
||||
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
|
||||
shutil.copyfile(tmpl_pkg, os.path.join(cache_dir, "package.json"))
|
||||
base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"]
|
||||
base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error", "--ignore-scripts"]
|
||||
npm = p_resolve_npm()
|
||||
if npm is None:
|
||||
logger.info("webapp-template: no npm available; skipping warm cache (workspace will install on first run)")
|
||||
@@ -374,13 +373,21 @@ 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."""
|
||||
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:
|
||||
@@ -570,7 +577,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")
|
||||
|
||||
@@ -75,6 +75,12 @@ WALK_SKIP_DIRS = frozenset({
|
||||
".ruff_cache",
|
||||
})
|
||||
|
||||
# OS/editor junk files that ride along in a workspace but are noise in an export.
|
||||
WALK_SKIP_FILES = frozenset({
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
})
|
||||
|
||||
# Poll-payload guard, NOT an editor limit: the endpoint re-serializes the whole tree every 2 s, so a multi-MB bundle would peg the backend; 2 MB clears real hand-authored apps but traps minified bundles/sourcemaps, which are reported out-of-band (the `truncated` map) and NEVER stubbed, so a placeholder can't round-trip back into storage and destroy the real source.
|
||||
P_WALK_MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
@@ -93,6 +99,8 @@ def walk_directory(folder: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||
# Mutate `dirs` in place; that's how os.walk skips a subtree. Doing it here means we never even stat the children, so a 10k-file `.venv/` costs ~one stat (on the dir itself) instead of 10k.
|
||||
dirs[:] = [d for d in dirs if d not in WALK_SKIP_DIRS]
|
||||
for fname in filenames:
|
||||
if fname in WALK_SKIP_FILES:
|
||||
continue
|
||||
full_path = os.path.join(root, fname)
|
||||
# Normalize to forward-slash keys so the frontend's `path.split('/')` and `.startsWith(prefix)` checks work the same on Windows (where os.sep is '\\') as on macOS. Without this, every workspace file came back as `backend\\app.py` on Windows and the file tree silently mis-parsed.
|
||||
rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/")
|
||||
|
||||
@@ -12,7 +12,7 @@ import socket
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.apps.outputs.models import Output
|
||||
from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS, save, load_output
|
||||
from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS, WALK_SKIP_FILES, save, load_output
|
||||
from backend.config.paths import OUTPUTS_DIR, OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
|
||||
@@ -57,6 +57,8 @@ class AppExportable:
|
||||
# .env is install-specific (absolute paths + port); .env.example travels instead.
|
||||
if fn == ".env":
|
||||
continue
|
||||
if fn in WALK_SKIP_FILES:
|
||||
continue
|
||||
full = os.path.join(root, fn)
|
||||
if os.path.islink(full):
|
||||
continue
|
||||
@@ -146,6 +148,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 +161,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
|
||||
|
||||
@@ -93,6 +93,8 @@ class DashboardExportable:
|
||||
nbid = "browser-" + uuid4().hex[:10]
|
||||
c = dict(card)
|
||||
c["browser_id"] = nbid
|
||||
# Re-stamp the home dashboard, else the card keeps the source's id and the anti-bleed render guard (DashboardCardLayer keepAliveHidden) hides it on the imported dashboard.
|
||||
c["dashboard_id"] = new_did
|
||||
spawn = c.get("spawned_by")
|
||||
c["spawned_by"] = remap.local(spawn) if spawn else None
|
||||
browser_cards[nbid] = c
|
||||
|
||||
@@ -389,7 +389,7 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
|
||||
"ABID": {"output_id": "ABID", "parent_session_id": "SBID"},
|
||||
"ABID2": {"output_id": "ABID2", "parent_session_id": "GONE"},
|
||||
},
|
||||
"browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}},
|
||||
"browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID", "dashboard_id": "OLD_DASH"}},
|
||||
"expanded_session_ids": ["SBID", "ORPHAN"],
|
||||
}}
|
||||
remap.assign("ABID2", "newapp2")
|
||||
@@ -399,6 +399,8 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
|
||||
assert L["view_cards"]["newapp"]["parent_session_id"] == "newsess"
|
||||
assert L["view_cards"]["newapp2"]["parent_session_id"] is None # parent not in bundle
|
||||
assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess"
|
||||
# The card's home dashboard must be re-stamped to the new id, else the anti-bleed render guard hides it on the import.
|
||||
assert list(L["browser_cards"].values())[0]["dashboard_id"] == did
|
||||
assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped
|
||||
|
||||
|
||||
|
||||
+154
-32
@@ -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,43 +1722,68 @@ 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 <webview> 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'] },
|
||||
// Strip X-Frame-Options and CSP frame-ancestors directives on iframe subframe loads so the Windows BrowserCard iframe fallback (used because <webview> 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 });
|
||||
},
|
||||
);
|
||||
};
|
||||
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.responseHeaders || {}) };
|
||||
const headers = { ...(details.requestHeaders || {}) };
|
||||
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];
|
||||
if (lk === 'sec-ch-ua' || lk === 'sec-ch-ua-full-version-list') {
|
||||
headers[k] = addGoogleChromeBrand(headers[k]);
|
||||
}
|
||||
}
|
||||
callback({ responseHeaders: headers });
|
||||
callback({ requestHeaders: headers });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1951,6 +1979,39 @@ 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 (_) {}
|
||||
}
|
||||
|
||||
// In-page browser shortcuts (zoom, find, tab-cycle) for a focused <webview> guest. Keydowns inside a
|
||||
// guest never reach the host renderer, so we catch them here and forward the intent + the guest's
|
||||
// webContents id so the renderer can target that exact browser. Attached to guests ONLY: on the host
|
||||
// the renderer's own keydown handles canvas-vs-browser, and intercepting there would eat canvas zoom.
|
||||
function routeBrowserShortcut(event, input, webContentsId) {
|
||||
if (input.type !== 'keyDown' || input.alt) return;
|
||||
const mod = input.meta || input.control;
|
||||
const key = (input.key || '').toLowerCase();
|
||||
let action = null;
|
||||
if (mod && !input.shift && (key === '=' || key === '+')) action = 'zoom-in';
|
||||
else if (mod && !input.shift && key === '-') action = 'zoom-out';
|
||||
else if (mod && !input.shift && key === '0') action = 'zoom-reset';
|
||||
else if (mod && !input.shift && key === 'f') action = 'find';
|
||||
else if (mod && input.shift && key === 't') action = 'reopen-closed';
|
||||
else if (input.control && !input.meta && key === 'tab') action = input.shift ? 'tab-prev' : 'tab-next';
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:browser-shortcut', { action, webContentsId });
|
||||
} 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,
|
||||
@@ -1958,6 +2019,11 @@ 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);
|
||||
}
|
||||
if (contents.getType() === 'webview') {
|
||||
const wcId = contents.id;
|
||||
contents.on('before-input-event', (event, input) => routeBrowserShortcut(event, input, wcId));
|
||||
}
|
||||
|
||||
// Override the user-agent on popup BrowserWindows (i.e. anything created
|
||||
@@ -1993,7 +2059,7 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
contents.setWindowOpenHandler(({ url, disposition }) => {
|
||||
if (disposition === 'foreground-tab' || disposition === 'background-tab') {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('webview-new-window', url, contents.id);
|
||||
mainWindow.webContents.send('webview-new-window', url, contents.id, disposition);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
}
|
||||
@@ -2143,6 +2209,54 @@ 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(() => {});
|
||||
});
|
||||
|
||||
// Force the guest's PAGE WORLD to always report visible/foregrounded. When a kept-alive browser card sits on another dashboard it's parked off-screen; the page-visibility API then reads hidden, so a real-time app (Discord) backgrounds itself, drops its gateway socket, and on return can't resume the session -> "please log in again". The webview-preload patches this too but only in the isolated world (contextIsolation), so the page's OWN code never sees it; injecting here in the main world is what actually keeps Discord logged in while hidden. document.hasFocus is forced true for the same reason; visibilitychange/freeze/pagehide are swallowed so nothing downstream reacts to a backgrounding that, to us, never happens.
|
||||
contents.on('dom-ready', () => {
|
||||
contents.executeJavaScript(`
|
||||
(function(){
|
||||
try {
|
||||
if (window.__openswarm_vis__) return; window.__openswarm_vis__ = true;
|
||||
var def = function(o, k, v){ try { Object.defineProperty(o, k, { get: function(){ return v; }, configurable: true }); } catch(e){} };
|
||||
def(document, 'hidden', false);
|
||||
def(document, 'visibilityState', 'visible');
|
||||
def(document, 'webkitHidden', false);
|
||||
def(document, 'webkitVisibilityState', 'visible');
|
||||
try { document.hasFocus = function(){ return true; }; } catch(e){}
|
||||
var swallow = function(e){ e.stopImmediatePropagation(); };
|
||||
['visibilitychange','webkitvisibilitychange','freeze','pagehide'].forEach(function(t){
|
||||
window.addEventListener(t, swallow, true);
|
||||
document.addEventListener(t, swallow, 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 <script> injection from the
|
||||
@@ -2440,6 +2554,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
|
||||
|
||||
+17
-1
@@ -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),
|
||||
@@ -101,11 +103,25 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
},
|
||||
|
||||
onWebviewNewWindow: (cb) => {
|
||||
const listener = (_event, url, webContentsId) => cb(url, webContentsId);
|
||||
const listener = (_event, url, webContentsId, disposition) => cb(url, webContentsId, disposition);
|
||||
ipcRenderer.on('webview-new-window', listener);
|
||||
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);
|
||||
},
|
||||
|
||||
// In-page browser shortcuts (zoom/find/tab-cycle) from a focused guest webview, carrying the guest's webContents id so the renderer targets that exact browser.
|
||||
onBrowserShortcut: (cb) => {
|
||||
const listener = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on('openswarm:browser-shortcut', listener);
|
||||
return () => ipcRenderer.removeListener('openswarm:browser-shortcut', listener);
|
||||
},
|
||||
|
||||
// Deep-link callback: fires when the OS opens the app with an
|
||||
// openswarm://auth?token=... URL (after Stripe-hosted checkout).
|
||||
onAuthUrl: (cb) => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 { getWebview } from '@/shared/browserRegistry';
|
||||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import Box from '@mui/material/Box';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
@@ -41,7 +44,7 @@ import { shallowEqual } from 'react-redux';
|
||||
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { setInstalling } from '@/shared/state/updateSlice';
|
||||
@@ -254,13 +257,14 @@ const AppShell: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => {
|
||||
const openUrlInBrowser = useCallback((url: string, webContentsId?: number, background?: boolean) => {
|
||||
const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/);
|
||||
if (dashMatch) {
|
||||
if (webContentsId != null) {
|
||||
const browserId = findBrowserByWebContentsId(webContentsId);
|
||||
if (browserId) {
|
||||
dispatch(addBrowserTab({ browserId, url, makeActive: true }));
|
||||
// Middle-click / background-tab disposition: add the tab but don't steal focus from the current one, like a real browser.
|
||||
dispatch(addBrowserTab({ browserId, url, makeActive: !background }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -314,15 +318,79 @@ const AppShell: React.FC = () => {
|
||||
if (!w.openswarm?.onWebviewNewWindow) return;
|
||||
let lastUrl = '';
|
||||
let lastTime = 0;
|
||||
return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => {
|
||||
return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number, disposition?: string) => {
|
||||
const now = Date.now();
|
||||
if (url === lastUrl && now - lastTime < 1000) return;
|
||||
lastUrl = url;
|
||||
lastTime = now;
|
||||
openUrlInBrowser(url, webContentsId);
|
||||
openUrlInBrowser(url, webContentsId, disposition === 'background-tab');
|
||||
});
|
||||
}, [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 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 fall back to a full app reload, since reloading the renderer destroys every webview and wipes its session. To deliberately reload OpenSwarm itself, use View > Reload.
|
||||
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 */ } }
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Zoom / find / tab-cycle from a focused browser GUEST (keydowns inside a webview can't reach this document, so main forwards them with the guest's id). Targets that exact browser; the host-focused counterparts live in the keydown below + useCanvasControls (zoom).
|
||||
useEffect(() => {
|
||||
const w = window as any;
|
||||
if (!w.openswarm?.onBrowserShortcut) return;
|
||||
return w.openswarm.onBrowserShortcut((payload: { action: string; webContentsId: number }) => {
|
||||
// Reopen-last-closed is global (no target browser), so handle it before the per-browser id guard.
|
||||
if (payload.action === 'reopen-closed') { dispatch(reopenLastClosed()); return; }
|
||||
const id = findBrowserByWebContentsId(payload.webContentsId) ?? getLastInteractedBrowser();
|
||||
if (!id) return;
|
||||
switch (payload.action) {
|
||||
case 'zoom-in': applyBrowserZoom(id, 1); break;
|
||||
case 'zoom-out': applyBrowserZoom(id, -1); break;
|
||||
case 'zoom-reset': applyBrowserZoom(id, 0); break;
|
||||
case 'find': window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } })); break;
|
||||
case 'tab-next': dispatch(cycleBrowserTab({ browserId: id, dir: 1 })); break;
|
||||
case 'tab-prev': dispatch(cycleBrowserTab({ browserId: id, dir: -1 })); break;
|
||||
}
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
// Host-focused Ctrl/Cmd+F (find) and Ctrl+Tab (cycle) when a browser is the last thing you touched. Zoom keys aren't here: they share the +/-/0 keys with canvas zoom, so useCanvasControls owns that branch.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const id = getLastInteractedBrowser();
|
||||
// Require a LIVE webview: a stale id (its card was closed) means no browser is focused, so let the canvas shortcuts (e.g. card-search Cmd+F) handle the key instead.
|
||||
if (!id || !getWebview(id)) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const typing = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable;
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && (e.key || '').toLowerCase() === 'f' && !typing) {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } }));
|
||||
} else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
dispatch(cycleBrowserTab({ browserId: id, dir: e.shiftKey ? -1 : 1 }));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
|
||||
}, [sidebarWidth]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
fadeGlowingAgentCard,
|
||||
clearGlowingAgentCard,
|
||||
removeCard,
|
||||
recordClosedCard,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
|
||||
@@ -625,6 +626,8 @@ const AgentCard: React.FC<Props> = ({
|
||||
if (linkedWorkflowSidecarId) {
|
||||
dispatch(setCardSidecar({ workflowId: linkedWorkflowSidecarId, sessionId: null, kind: null }));
|
||||
}
|
||||
// Record for Cmd+Shift+T BEFORE removeCard wipes the position, but only on a real close (the glow branch just clears a tether, it doesn't close the session).
|
||||
if (!glowEntry) dispatch(recordClosedCard({ kind: 'agent', id: session.id }));
|
||||
dispatch(collapseSession(session.id));
|
||||
dispatch(removeCard(session.id));
|
||||
if (glowEntry) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
updateBrowserTabTitle,
|
||||
updateBrowserTabFavicon,
|
||||
reorderBrowserTab,
|
||||
recordClosedCard,
|
||||
type BrowserTab,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
setActiveTab as setRegistryActiveTab,
|
||||
type BrowserWebview,
|
||||
} from '@/shared/browserRegistry';
|
||||
import { setLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import BrowserFindBar from './BrowserFindBar';
|
||||
import { useBrowserActivity } from '@/shared/useBrowserActivity';
|
||||
import { getActionLabel } from '@/shared/browserCommandHandler';
|
||||
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
|
||||
@@ -120,8 +123,11 @@ 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';
|
||||
|
||||
// Sync exposure set at preload boot; async API fallback for older builds.
|
||||
const webviewPreloadPath: string | undefined = isElectron
|
||||
@@ -153,6 +159,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;
|
||||
@@ -165,7 +173,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();
|
||||
@@ -210,6 +218,9 @@ const BrowserCard: React.FC<Props> = ({
|
||||
// Electron webviews can't trigger OS platform auth; preload sends "passkey-detected" and we explain via modal.
|
||||
const [passkeyDialogOpen, setPasskeyDialogOpen] = useState(false);
|
||||
const [crashedTabs, setCrashedTabs] = useState<Set<string>>(new Set());
|
||||
// Ctrl/Cmd+F find bar; focusSignal re-focuses the input each time Ctrl+F fires while it's already open.
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findFocusSignal, setFindFocusSignal] = useState(0);
|
||||
const updateTabLocal = useCallback((tabId: string, update: Partial<TabLocalState>) => {
|
||||
setTabLocalStates((prev) => {
|
||||
const existing = prev[tabId] ?? { loading: false, canGoBack: false, canGoForward: false };
|
||||
@@ -238,6 +249,17 @@ const BrowserCard: React.FC<Props> = ({
|
||||
setRegistryActiveTab(browserId, activeTabId);
|
||||
}, [browserId, activeTabId]);
|
||||
|
||||
// Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input.
|
||||
useEffect(() => {
|
||||
const onFind = (e: Event) => {
|
||||
if ((e as CustomEvent).detail?.browserId !== browserId) return;
|
||||
setFindOpen(true);
|
||||
setFindFocusSignal((n) => n + 1);
|
||||
};
|
||||
window.addEventListener('openswarm:browser-find', onFind as EventListener);
|
||||
return () => window.removeEventListener('openswarm:browser-find', onFind as EventListener);
|
||||
}, [browserId]);
|
||||
|
||||
// A resumed webview remounts at about:blank; dropping the init markers lets doLoad re-fire.
|
||||
useEffect(() => {
|
||||
if (suspendedSnap) initializedTabs.current.clear();
|
||||
@@ -322,6 +344,9 @@ const BrowserCard: React.FC<Props> = ({
|
||||
},
|
||||
}),
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -431,6 +456,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(recordClosedCard({ kind: 'browser', id: browserId }));
|
||||
removeBrowserCardCleanly(browserId, dispatch);
|
||||
}, [dispatch, browserId]);
|
||||
|
||||
@@ -441,8 +467,11 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Closing the last tab destroys the whole card, so record it as a browser-card close (reopen brings the card back), not a tab close.
|
||||
if (tabs.length <= 1) dispatch(recordClosedCard({ kind: 'browser', id: browserId }));
|
||||
else dispatch(recordClosedCard({ kind: 'tab', id: tabId, browserId }));
|
||||
dispatch(removeBrowserTab({ browserId, tabId }));
|
||||
}, [dispatch, browserId]);
|
||||
}, [dispatch, browserId, tabs.length]);
|
||||
|
||||
const handleSwitchTab = useCallback((tabId: string) => {
|
||||
dispatch(setActiveBrowserTab({ browserId, tabId }));
|
||||
@@ -720,6 +749,8 @@ const BrowserCard: React.FC<Props> = ({
|
||||
data-select-type="browser-card"
|
||||
data-select-id={browserId}
|
||||
data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })}
|
||||
// Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing.
|
||||
data-keepalive-hidden={keepAliveHidden ? '1' : undefined}
|
||||
onPointerDownCapture={() => onBringToFront?.(browserId, 'browser')}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
@@ -731,11 +762,13 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
// Kept-alive card from another dashboard: parked far off-screen so its webview surface can't bleed onto the dashboard you're viewing; click-through, webContents stays mounted.
|
||||
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,
|
||||
@@ -1084,6 +1117,9 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
{/* Browser body: stacked webviews */}
|
||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
{findOpen && !suspendedSnap && (
|
||||
<BrowserFindBar browserId={browserId} focusSignal={findFocusSignal} onClose={() => setFindOpen(false)} />
|
||||
)}
|
||||
{isElementSelectMode && (
|
||||
<Box sx={{ position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none' }} />
|
||||
)}
|
||||
@@ -1136,6 +1172,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
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}
|
||||
@@ -1468,6 +1505,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface BrowserFindBarProps {
|
||||
browserId: string;
|
||||
focusSignal: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// In-page find (Ctrl/Cmd+F): drives the active tab's webview findInPage with a Chrome-style match counter + up/down/Enter nav, clearing the highlight on close.
|
||||
export default function BrowserFindBar({ browserId, focusSignal, onClose }: BrowserFindBarProps): React.ReactElement {
|
||||
const c = useClaudeTokens();
|
||||
const [query, setQuery] = useState('');
|
||||
const [result, setResult] = useState<{ active: number; total: number }>({ active: 0, total: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fresh search omits findNext (passing findNext:false to a webview eats the found-in-page result, an Electron quirk); navigate=true does next/prev.
|
||||
const search = useCallback((text: string, navigate: boolean, forward: boolean) => {
|
||||
const wv = getWebview(browserId);
|
||||
if (!wv) return;
|
||||
try {
|
||||
if (!text) {
|
||||
wv.stopFindInPage('clearSelection');
|
||||
setResult({ active: 0, total: 0 });
|
||||
return;
|
||||
}
|
||||
if (navigate) wv.findInPage(text, { findNext: true, forward });
|
||||
else wv.findInPage(text);
|
||||
} catch {
|
||||
// torn-down webview; nothing to find
|
||||
}
|
||||
}, [browserId]);
|
||||
|
||||
useEffect(() => {
|
||||
const wv = getWebview(browserId);
|
||||
if (!wv) return;
|
||||
const onFound = (e: any) => {
|
||||
const r = e?.result;
|
||||
if (r && typeof r.matches === 'number') {
|
||||
setResult({ active: r.activeMatchOrdinal ?? 0, total: r.matches });
|
||||
}
|
||||
};
|
||||
wv.addEventListener('found-in-page', onFound as any);
|
||||
return () => {
|
||||
try {
|
||||
wv.removeEventListener('found-in-page', onFound as any);
|
||||
wv.stopFindInPage('clearSelection');
|
||||
} catch {
|
||||
// webview already gone
|
||||
}
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, [focusSignal]);
|
||||
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
setQuery(text);
|
||||
search(text, false, true);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (query) search(query, true, !e.shiftKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 12,
|
||||
zIndex: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
boxShadow: c.shadow.lg,
|
||||
}}
|
||||
>
|
||||
<InputBase
|
||||
inputRef={inputRef}
|
||||
value={query}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Find in page"
|
||||
sx={{ fontSize: 13, color: c.text.primary, width: 160, '& input': { p: 0 } }}
|
||||
/>
|
||||
<Box sx={{ fontSize: 12, color: c.text.tertiary, minWidth: 44, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{query ? `${result.active}/${result.total}` : ''}
|
||||
</Box>
|
||||
<IconButton size="small" disabled={!query} onClick={() => search(query, true, false)} sx={{ color: c.text.secondary }}>
|
||||
<KeyboardArrowUpIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" disabled={!query} onClick={() => search(query, true, true)} sx={{ color: c.text.secondary }}>
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: c.text.secondary }}>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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, recordClosedCard } 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,8 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(removeViewCard(output.id));
|
||||
dispatch(recordClosedCard({ kind: 'view', id: output.id }));
|
||||
void removeViewCardCleanly(output.id, dispatch);
|
||||
};
|
||||
|
||||
const handleRefresh = (e: React.MouseEvent) => {
|
||||
@@ -686,7 +688,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
This app's files are missing.
|
||||
</Typography>
|
||||
<Typography
|
||||
onClick={() => dispatch(removeViewCard(output.id))}
|
||||
onClick={() => void removeViewCardCleanly(output.id, dispatch)}
|
||||
sx={{
|
||||
color: tokens.accent.primary,
|
||||
fontSize: '0.85rem',
|
||||
@@ -713,6 +715,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
return (
|
||||
<ViewPreview
|
||||
ref={previewRef}
|
||||
registryId={output.id}
|
||||
serveUrl={url}
|
||||
frontendCode={output.files?.['index.html'] ?? ''}
|
||||
inputData={inputData}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
removeNote,
|
||||
updateNoteContent,
|
||||
setNoteColor,
|
||||
recordClosedCard,
|
||||
NoteColor,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
@@ -238,6 +239,7 @@ const NoteCard: React.FC<Props> = ({
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(recordClosedCard({ kind: 'note', id: noteId }));
|
||||
dispatch(removeNote(noteId));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
|
||||
import { setCanvasInteractionActive } from '@/shared/canvasInteractionState';
|
||||
import { getLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 3.0;
|
||||
@@ -497,15 +500,21 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
setCmdHeld(true);
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// If the last thing you touched was a browser card, +/-/0 zooms THAT page (like a real browser); otherwise it zooms the dashboard canvas.
|
||||
const focusedBrowser = getLastInteractedBrowser();
|
||||
const browserWv = focusedBrowser ? getWebview(focusedBrowser) : undefined;
|
||||
if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
resetZoomRef.current();
|
||||
if (browserWv) applyBrowserZoom(focusedBrowser as string, 0);
|
||||
else resetZoomRef.current();
|
||||
} else if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
zoomInRef.current();
|
||||
if (browserWv) applyBrowserZoom(focusedBrowser as string, 1);
|
||||
else zoomInRef.current();
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
zoomOutRef.current();
|
||||
if (browserWv) applyBrowserZoom(focusedBrowser as string, -1);
|
||||
else zoomOutRef.current();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -543,6 +552,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
|
||||
const prev = stateRef.current;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i] as HTMLElement;
|
||||
// Skip a kept-alive browser card from another dashboard (parked off-screen): fitting to it pans the canvas right onto it, which is the cross-dashboard bleed. On an empty dashboard this leaves nothing to fit, so the !isFinite reset below restores an identity transform and the off-screen card stays off-screen.
|
||||
if (child.getAttribute?.('data-keepalive-hidden') === '1' || child.querySelector?.('[data-keepalive-hidden="1"]')) continue;
|
||||
const r = children[i].getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
const sx = (r.left - vRect.left - prev.panX) / prev.zoom;
|
||||
|
||||
@@ -2,9 +2,12 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
|
||||
import { removeViewCard, removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
|
||||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||||
import { getLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import type { useDashboardSelection } from '../state/useDashboardSelection';
|
||||
|
||||
type Selection = ReturnType<typeof useDashboardSelection>;
|
||||
@@ -27,7 +30,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,28 +75,48 @@ 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(recordClosedCard({ kind: 'agent', id }));
|
||||
dispatch(closeSession({ sessionId: id }));
|
||||
} else if (type === 'view') {
|
||||
dispatch(removeViewCard(id));
|
||||
dispatch(recordClosedCard({ kind: 'view', id }));
|
||||
viewIds.push(id);
|
||||
} else if (type === 'browser') {
|
||||
dispatch(recordClosedCard({ kind: 'browser', id }));
|
||||
removeBrowserCardCleanly(id, dispatch);
|
||||
} else if (type === 'note') {
|
||||
dispatch(recordClosedCard({ kind: 'note', id }));
|
||||
dispatch(removeNote(id));
|
||||
} else if (type === 'workflow') {
|
||||
dispatch(recordClosedCard({ kind: 'workflow', id }));
|
||||
dispatch(removeWorkflowCard(id));
|
||||
dispatch(closeWorkflowCard(id));
|
||||
} else if (type === 'workflows-hub') {
|
||||
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);
|
||||
return () => window.removeEventListener('keydown', handleDelete);
|
||||
}, [selection, dispatch]);
|
||||
|
||||
// Cmd/Ctrl+Shift+T reopens the most recently closed card (browser, agent, note, app, workflow, or browser tab), like a browser's reopen-closed-tab. The guest-focused case routes through main -> AppShell.
|
||||
useEffect(() => {
|
||||
const handleReopen = (e: KeyboardEvent) => {
|
||||
if (!isActive) return;
|
||||
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey || e.key.toLowerCase() !== 't') return;
|
||||
e.preventDefault();
|
||||
dispatch(reopenLastClosed());
|
||||
};
|
||||
window.addEventListener('keydown', handleReopen);
|
||||
return () => window.removeEventListener('keydown', handleReopen);
|
||||
}, [isActive, dispatch]);
|
||||
|
||||
// Cmd/Ctrl+A selects every card so it can be deleted in one go. Skipped inside text fields so Cmd+A there still selects text, not cards.
|
||||
useEffect(() => {
|
||||
const handleSelectAll = (e: KeyboardEvent) => {
|
||||
@@ -113,6 +136,9 @@ export function useDashboardShortcuts({
|
||||
const handleSearch = (e: KeyboardEvent) => {
|
||||
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'f') return;
|
||||
// When you're in a LIVE browser card, Cmd+F is find-in-page (handled in AppShell), not card search. A stale id (its card was closed) must NOT suppress the palette, so require the webview to still exist.
|
||||
const fb = getLastInteractedBrowser();
|
||||
if (fb && getWebview(fb)) return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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';
|
||||
@@ -104,7 +105,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,
|
||||
@@ -312,7 +312,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]);
|
||||
// Browser cards from OTHER dashboards stay mounted (so their webContents + session survive a switch, no Discord logout) but get rendered parked far off-screen by the card layer; that off-screen park reliably hides even a heavy live page (Discord), CDP-verified. Kept OUT of `browserCards` so save/bounds/keyboard-nav only ever see THIS dashboard's cards (no cross-dashboard leak), and tagging every card's home dashboard is what stops the real bleed (an untagged card renders as home everywhere).
|
||||
const keepAliveBrowserCards = useMemo(() => {
|
||||
const out: typeof allBrowserCards = {};
|
||||
for (const [id, bc] of Object.entries(allBrowserCards)) {
|
||||
if (bc.dashboard_id && bc.dashboard_id !== dashboardId) 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,
|
||||
|
||||
@@ -20,11 +20,15 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) =>
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [eraseText, setEraseText] = useState('');
|
||||
const [err, setErr] = useState<string | null>(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 }) =>
|
||||
<Button variant="outlined" size="small" onClick={() => { setErr(null); setResetOpen(true); }} sx={rowBtnSx}>Reset</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<Box>
|
||||
<Typography sx={labelSx}>Clear browsing data</Typography>
|
||||
<Typography sx={descSx}>Signs you out of sites opened in browser cards and clears their cookies, cache, and local storage. Your chats, apps, and settings stay.</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" onClick={() => { setErr(null); setClearedOk(false); setClearOpen(true); }} sx={rowBtnSx}>Clear</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={rowSx}>
|
||||
<Box>
|
||||
<Typography sx={{ ...labelSx, color: c.status.error }}>Erase all content and settings</Typography>
|
||||
@@ -120,6 +150,24 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) =>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={clearOpen} onClose={closeAll} PaperProps={{ sx: dialogPaperSx }}>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={titleSx}>{clearedOk ? 'Browsing data cleared' : 'Clear browsing data?'}</Typography>
|
||||
<Typography sx={bodySx}>{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.'}</Typography>
|
||||
{err && <Typography sx={errSx}>{err}</Typography>}
|
||||
<Box sx={actionRowSx}>
|
||||
{clearedOk ? (
|
||||
<Button onClick={closeAll} sx={{ color: c.accent.primary, textTransform: 'none', fontWeight: 600 }}>Done</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={closeAll} disabled={busy} sx={cancelSx}>Cancel</Button>
|
||||
<Button onClick={doClearBrowser} disabled={busy} sx={{ color: c.accent.primary, textTransform: 'none', fontWeight: 600 }}>{busy ? 'Clearing…' : 'Clear'}</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={eraseOpen} onClose={closeAll} PaperProps={{ sx: dialogPaperSx }}>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={titleSx}>Erase all content and settings?</Typography>
|
||||
|
||||
@@ -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 <webview> 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<ViewPreviewHandle, Props>(({
|
||||
onContentLoad,
|
||||
interactive = false,
|
||||
onAppClicked,
|
||||
registryId,
|
||||
}, ref) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const webviewRef = useRef<any>(null);
|
||||
@@ -276,6 +280,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
|
||||
};
|
||||
}, [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;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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 {
|
||||
lastInteractedBrowserId = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -27,6 +27,10 @@ export interface BrowserWebview extends HTMLElement {
|
||||
executeJavaScript: (code: string) => Promise<any>;
|
||||
sendInputEvent: (event: any) => void;
|
||||
getWebContentsId: () => number;
|
||||
getZoomLevel: () => number;
|
||||
setZoomLevel: (level: number) => void;
|
||||
findInPage: (text: string, options?: { forward?: boolean; findNext?: boolean; matchCase?: boolean }) => number;
|
||||
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void;
|
||||
addEventListener: (event: string, listener: (...args: any[]) => void, options?: boolean | AddEventListenerOptions) => void;
|
||||
removeEventListener: (event: string, listener: (...args: any[]) => void, options?: boolean | EventListenerOptions) => void;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
|
||||
// Zoom a browser card's active page like a real browser (dir 1/-1/0), separate from canvas zoom; one shared step+clamp so guest-focused and host-focused zoom never drift apart.
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 5;
|
||||
|
||||
export function applyBrowserZoom(browserId: string, dir: -1 | 0 | 1): void {
|
||||
const wv = getWebview(browserId);
|
||||
if (!wv) return;
|
||||
try {
|
||||
if (dir === 0) {
|
||||
wv.setZoomLevel(0);
|
||||
return;
|
||||
}
|
||||
const raw = typeof wv.getZoomLevel === 'function' ? wv.getZoomLevel() : 0;
|
||||
const current = typeof raw === 'number' && isFinite(raw) ? raw : 0;
|
||||
const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current + dir * ZOOM_STEP));
|
||||
wv.setZoomLevel(next);
|
||||
} catch {
|
||||
// torn-down webview; nothing to zoom
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
@@ -15,17 +15,20 @@ export function useLastDashboardId(): [string | null, (id: string | null) => voi
|
||||
}
|
||||
});
|
||||
|
||||
// Watch URL; update sticky id on /dashboard/:id. Do NOT clear when URL stops matching.
|
||||
// The id from the CURRENT url, read synchronously in render: a dashboard switch must update dashboardId on the SAME render the route changes. Deferring it to the effect below left a one-frame window where the OLD dashboard was still the active id, so a kept-alive browser card from it flashed onto the new dashboard before parking off-screen (the cross-dashboard bleed).
|
||||
const routeId = location.pathname.match(/^\/dashboard\/([^/]+)/)?.[1] ?? null;
|
||||
const effectiveId = routeId ?? lastId;
|
||||
|
||||
// Persist the route id so it stays sticky when the url stops matching a dashboard (settings etc.). Do NOT clear when it stops matching.
|
||||
useEffect(() => {
|
||||
const match = location.pathname.match(/^\/dashboard\/([^/]+)/);
|
||||
if (match && match[1] && match[1] !== lastId) {
|
||||
setLastIdState(match[1]);
|
||||
if (routeId && routeId !== lastId) {
|
||||
setLastIdState(routeId);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, match[1]);
|
||||
localStorage.setItem(STORAGE_KEY, routeId);
|
||||
} catch {}
|
||||
(window as any)[WINDOW_KEY] = match[1];
|
||||
setLastDashboardId(routeId);
|
||||
}
|
||||
}, [location.pathname, lastId]);
|
||||
}, [routeId, lastId]);
|
||||
|
||||
const setLastId = useCallback((id: string | null) => {
|
||||
setLastIdState(id);
|
||||
@@ -36,12 +39,8 @@ 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];
|
||||
return [effectiveId, setLastId];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1147,7 +1147,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);
|
||||
@@ -1160,7 +1160,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));
|
||||
@@ -1246,7 +1246,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;
|
||||
@@ -1313,7 +1313,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)) {
|
||||
@@ -1385,6 +1385,7 @@ const agentsSlice = createSlice({
|
||||
...session,
|
||||
name: normalizeSessionName(session.name),
|
||||
tool_group_meta: session.tool_group_meta ?? {},
|
||||
pending_approvals: session.pending_approvals ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@reduxjs/toolkit';
|
||||
import { launchAndSendFirstMessage } from './agentsSlice';
|
||||
import { launchAndSendFirstMessage, resumeSession } 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<
|
||||
@@ -111,6 +112,19 @@ export interface NotePosition {
|
||||
export const DEFAULT_NOTE_W = 240;
|
||||
export const DEFAULT_NOTE_H = 200;
|
||||
|
||||
// One entry in the Ctrl/Cmd+Shift+T "reopen last closed" stack: a full snapshot for browser/view/workflow/note/tab, just the session id for an agent (its session is brought back via resumeSession).
|
||||
export type ClosedCard =
|
||||
| { uid: string; kind: 'browser'; closedAt: number; card: BrowserCardPosition }
|
||||
| { uid: string; kind: 'view'; closedAt: number; card: ViewCardPosition }
|
||||
| { uid: string; kind: 'workflow'; closedAt: number; card: WorkflowCardPosition }
|
||||
| { uid: string; kind: 'note'; closedAt: number; note: NotePosition }
|
||||
| { uid: string; kind: 'tab'; closedAt: number; browserId: string; index: number; tab: BrowserTab }
|
||||
| { uid: string; kind: 'agent'; closedAt: number; sessionId: string; position: CardPosition | null };
|
||||
|
||||
export type ClosedCardKind = ClosedCard['kind'];
|
||||
|
||||
const RECENTLY_CLOSED_CAP = 25;
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
@@ -119,6 +133,8 @@ export interface DashboardLayoutState {
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
closedCardPositions: Record<string, CardPosition>;
|
||||
/** Session-global LIFO undo stack for Ctrl/Cmd+Shift+T; survives dashboard switches (resetLayout leaves it alone). */
|
||||
recentlyClosed: ClosedCard[];
|
||||
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
|
||||
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
|
||||
persistedExpandedSessionIds: string[];
|
||||
@@ -165,6 +181,7 @@ const initialState: DashboardLayoutState = {
|
||||
workflowsHub: null,
|
||||
notes: {},
|
||||
closedCardPositions: {},
|
||||
recentlyClosed: [],
|
||||
glowingBrowserCards: {},
|
||||
glowingAgentCards: {},
|
||||
persistedExpandedSessionIds: [],
|
||||
@@ -740,6 +757,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;
|
||||
},
|
||||
@@ -763,6 +782,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
width: w,
|
||||
height: h,
|
||||
zOrder: card.zOrder || state.nextZOrder++,
|
||||
// An agent-spawned card must carry its home dashboard or it renders on EVERY dashboard; trust the backend's tag, fall back to the current dashboard so an old/untagged payload can't bleed.
|
||||
dashboard_id: card.dashboard_id ?? getLastDashboardId() ?? undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1056,6 +1077,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
width: width || DEFAULT_BROWSER_CARD_W,
|
||||
height: height || DEFAULT_BROWSER_CARD_H,
|
||||
zOrder: state.nextZOrder++,
|
||||
// Pasted onto the dashboard the user is looking at, else it bleeds onto every dashboard.
|
||||
dashboard_id: getLastDashboardId() ?? undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1119,6 +1142,21 @@ const dashboardLayoutSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab: move to the next/previous tab, wrapping around. dir 1 = forward.
|
||||
cycleBrowserTab(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; dir: 1 | -1 }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card || card.tabs.length < 2) return;
|
||||
const idx = card.tabs.findIndex((t) => t.id === card.activeTabId);
|
||||
if (idx === -1) return;
|
||||
const n = card.tabs.length;
|
||||
const next = card.tabs[(idx + action.payload.dir + n) % n];
|
||||
card.activeTabId = next.id;
|
||||
card.url = next.url;
|
||||
},
|
||||
|
||||
updateBrowserTabUrl(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string; url: string }>
|
||||
@@ -1274,6 +1312,75 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.pendingFocusNoteId = null;
|
||||
},
|
||||
|
||||
// Snapshot a card onto the reopen stack RIGHT BEFORE it's closed (the data must still be in state). Dispatch only from genuine user closes, not programmatic teardown.
|
||||
recordClosedCard(
|
||||
state,
|
||||
action: PayloadAction<{ kind: ClosedCardKind; id: string; browserId?: string }>
|
||||
) {
|
||||
const { kind, id, browserId } = action.payload;
|
||||
const closedAt = Date.now();
|
||||
const uid = `${kind}-${id}-${closedAt}`;
|
||||
let entry: ClosedCard | null = null;
|
||||
if (kind === 'browser' && state.browserCards[id]) {
|
||||
entry = { uid, kind, closedAt, card: { ...state.browserCards[id], tabs: state.browserCards[id].tabs.map((t) => ({ ...t })) } };
|
||||
} else if (kind === 'view' && state.viewCards[id]) {
|
||||
entry = { uid, kind, closedAt, card: { ...state.viewCards[id] } };
|
||||
} else if (kind === 'workflow' && state.workflowCards[id]) {
|
||||
entry = { uid, kind, closedAt, card: { ...state.workflowCards[id] } };
|
||||
} else if (kind === 'note' && state.notes[id]) {
|
||||
entry = { uid, kind, closedAt, note: { ...state.notes[id] } };
|
||||
} else if (kind === 'agent') {
|
||||
entry = { uid, kind, closedAt, sessionId: id, position: state.cards[id] ? { ...state.cards[id] } : null };
|
||||
} else if (kind === 'tab' && browserId && state.browserCards[browserId]) {
|
||||
const card = state.browserCards[browserId];
|
||||
const index = card.tabs.findIndex((t) => t.id === id);
|
||||
// Last tab closing tears the whole card down; that's recorded as a 'browser' close instead, so skip.
|
||||
if (index >= 0 && card.tabs.length > 1) entry = { uid, kind, closedAt, browserId, index, tab: { ...card.tabs[index] } };
|
||||
}
|
||||
if (!entry) return;
|
||||
state.recentlyClosed.push(entry);
|
||||
if (state.recentlyClosed.length > RECENTLY_CLOSED_CAP) state.recentlyClosed.shift();
|
||||
},
|
||||
|
||||
// Re-insert a non-agent closed card (agents come back via resumeSession in the reopenLastClosed thunk). Lands on the current dashboard.
|
||||
restoreClosedCard(
|
||||
state,
|
||||
action: PayloadAction<{ entry: ClosedCard; dashboardId?: string }>
|
||||
) {
|
||||
const { entry, dashboardId } = action.payload;
|
||||
const zOrder = state.nextZOrder++;
|
||||
if (entry.kind === 'browser') {
|
||||
state.browserCards[entry.card.browser_id] = { ...entry.card, zOrder, dashboard_id: dashboardId ?? entry.card.dashboard_id };
|
||||
} else if (entry.kind === 'view') {
|
||||
state.viewCards[entry.card.output_id] = { ...entry.card, zOrder };
|
||||
} else if (entry.kind === 'workflow') {
|
||||
state.workflowCards[entry.card.workflow_id] = { ...entry.card, zOrder };
|
||||
} else if (entry.kind === 'note') {
|
||||
state.notes[entry.note.note_id] = { ...entry.note, zOrder };
|
||||
} else if (entry.kind === 'tab') {
|
||||
const card = state.browserCards[entry.browserId];
|
||||
if (card) {
|
||||
// Fresh id: reusing the old one makes BrowserCard think the tab is already initialized, so its webview never reloads the URL and sits at about:blank.
|
||||
const tab = { ...entry.tab, id: generateTabId() };
|
||||
card.tabs.splice(Math.min(entry.index, card.tabs.length), 0, tab);
|
||||
card.activeTabId = tab.id;
|
||||
card.url = tab.url;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
popClosedCard(state, action: PayloadAction<string>) {
|
||||
state.recentlyClosed = state.recentlyClosed.filter((e) => e.uid !== action.payload);
|
||||
},
|
||||
|
||||
// Pre-seed a resumed agent's old position so reconcileSessions drops its card back where it was, not in a fresh grid cell.
|
||||
seedClosedAgentPosition(
|
||||
state,
|
||||
action: PayloadAction<{ sessionId: string; position: CardPosition }>
|
||||
) {
|
||||
state.closedCardPositions[action.payload.sessionId] = action.payload.position;
|
||||
},
|
||||
|
||||
replaceDraftId(
|
||||
state,
|
||||
action: PayloadAction<{ oldId: string; newId: string }>
|
||||
@@ -1328,10 +1435,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 = {};
|
||||
@@ -1342,7 +1457,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.nextZOrder = 1;
|
||||
state.initialized = false;
|
||||
state.pendingFocusNoteId = null;
|
||||
state.suspendedBrowserCards = {};
|
||||
state.suspendedBrowserCards = keptSuspended;
|
||||
state.endingBrowserCards = {};
|
||||
state.pendingFocusWorkflowId = null;
|
||||
},
|
||||
@@ -1362,18 +1477,21 @@ 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)) {
|
||||
card.dashboard_id = ownerDashboardId;
|
||||
// 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)) {
|
||||
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)) {
|
||||
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);
|
||||
@@ -1468,6 +1586,7 @@ export const {
|
||||
addBrowserTab,
|
||||
removeBrowserTab,
|
||||
setActiveBrowserTab,
|
||||
cycleBrowserTab,
|
||||
updateBrowserTabUrl,
|
||||
updateBrowserTabTitle,
|
||||
updateBrowserTabFavicon,
|
||||
@@ -1507,7 +1626,30 @@ export const {
|
||||
setNoteColor,
|
||||
removeNote,
|
||||
clearPendingFocusNoteId,
|
||||
recordClosedCard,
|
||||
restoreClosedCard,
|
||||
popClosedCard,
|
||||
seedClosedAgentPosition,
|
||||
resetLayout,
|
||||
} = dashboardLayoutSlice.actions;
|
||||
|
||||
// Ctrl/Cmd+Shift+T: bring back the most recently closed card on the current dashboard. Agents resume from history (async); everything else is a synchronous re-insert. Best-effort: the entry is consumed even if an agent resume fails, so a dead session can't wedge the stack.
|
||||
export const reopenLastClosed = createAsyncThunk(
|
||||
'dashboardLayout/reopenLastClosed',
|
||||
async (_: void, { getState, dispatch }) => {
|
||||
const state = getState() as { dashboardLayout: DashboardLayoutState };
|
||||
const stack = state.dashboardLayout.recentlyClosed;
|
||||
if (stack.length === 0) return;
|
||||
const entry = stack[stack.length - 1];
|
||||
const dashboardId = getLastDashboardId() ?? undefined;
|
||||
if (entry.kind === 'agent') {
|
||||
if (entry.position) dispatch(seedClosedAgentPosition({ sessionId: entry.sessionId, position: entry.position }));
|
||||
await dispatch(resumeSession({ sessionId: entry.sessionId }));
|
||||
} else {
|
||||
dispatch(restoreClosedCard({ entry, dashboardId }));
|
||||
}
|
||||
dispatch(popClosedCard(entry.uid));
|
||||
}
|
||||
);
|
||||
|
||||
export default dashboardLayoutSlice.reducer;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 <webview>, leaving only a trivial surface to tear down. Bounded + fail-open.
|
||||
export async function quiesceViewWebview(outputId: string): Promise<void> {
|
||||
const wv = getViewWebview(outputId);
|
||||
if (!wv) return;
|
||||
try {
|
||||
await Promise.race([
|
||||
wv.loadURL('about:blank').catch(() => {}),
|
||||
new Promise<void>((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 <webview> 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<void> {
|
||||
await quiesceViewWebview(outputId);
|
||||
dispatch(removeViewCard(outputId));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Live app-card preview webviews keyed by output id. The delete path looks a card's <webview> 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<void>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ViewWebview>();
|
||||
|
||||
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);
|
||||
}
|
||||
Vendored
+4
-1
@@ -46,9 +46,12 @@ declare global {
|
||||
onDownloadProgress: (cb: (progress: OpenSwarmDownloadProgress) => void) => () => void;
|
||||
onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
|
||||
onUpdateError: (cb: (message: string) => void) => () => void;
|
||||
onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void;
|
||||
onWebviewNewWindow: (cb: (url: string, webContentsId: number, disposition?: string) => void) => () => void;
|
||||
onReloadShortcut?: (cb: () => void) => () => void;
|
||||
onBrowserShortcut?: (cb: (payload: { action: string; webContentsId: number }) => void) => () => void;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
hardReset?: () => Promise<void>;
|
||||
clearBrowserData?: () => Promise<{ ok: boolean }>;
|
||||
onAuthUrl?: (cb: (url: string) => void) => () => void;
|
||||
onOauthClaim?: (cb: (url: string) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -322,11 +322,13 @@ try {
|
||||
Write-Host "[3b] Downloading $NodeUrl..."
|
||||
Invoke-WebRequest -Uri $NodeUrl -OutFile $NodeZip -UseBasicParsing
|
||||
Expand-Archive -Path $NodeZip -DestinationPath $NodeExtract -Force
|
||||
# Ship just node.exe — npm/npx are unused at runtime (router + MCP
|
||||
# bundles are pre-built). Saves ~70 MB from the installer.
|
||||
$SrcNode = Join-Path $NodeExtract "node-$NodeVersion-win-x64\node.exe"
|
||||
$SrcRoot = Join-Path $NodeExtract "node-$NodeVersion-win-x64"
|
||||
$SrcNode = Join-Path $SrcRoot 'node.exe'
|
||||
if (-not (Test-Path $SrcNode)) { throw "node.exe not found at $SrcNode after extract" }
|
||||
Copy-Item -Force $SrcNode (Join-Path $NodeStageDir 'node.exe')
|
||||
# Bundle npm too so packaged apps with custom deps can `npm install` them. npm.cmd + node_modules\npm sit next to node.exe in the win dist; p_resolve_npm finds node_dir\npm.cmd.
|
||||
Copy-Item -Force (Join-Path $SrcRoot 'npm.cmd') (Join-Path $NodeStageDir 'npm.cmd')
|
||||
Copy-Item -Recurse -Force (Join-Path $SrcRoot 'node_modules') (Join-Path $NodeStageDir 'node_modules')
|
||||
$Size = (Get-Item (Join-Path $NodeStageDir 'node.exe')).Length / 1MB
|
||||
Write-Host ("[3b] Node {0} (x64) staged ({1:N1} MB)" -f $NodeVersion, $Size)
|
||||
} finally {
|
||||
|
||||
+10
-3
@@ -330,11 +330,18 @@ download_node_for_arch() {
|
||||
local tmp; tmp=$(mktemp -d)
|
||||
curl -fsSL --progress-bar -o "$tmp/node.tar.gz" "$url"
|
||||
tar xzf "$tmp/node.tar.gz" -C "$tmp"
|
||||
# Ship just the `node` binary. We don't need npm/npx/corepack at runtime —
|
||||
# all router + MCP code is pre-bundled. ~50 MB per arch -> ~25 MB after
|
||||
# gzip/dmg compression.
|
||||
cp "$tmp/node-${NODE_VERSION}-darwin-${arch}/bin/node" "$out_dir/bin/node"
|
||||
chmod +x "$out_dir/bin/node"
|
||||
# Bundle npm too so packaged apps with custom deps can `npm install` them (the bare node can't).
|
||||
mkdir -p "$out_dir/lib/node_modules"
|
||||
cp -R "$tmp/node-${NODE_VERSION}-darwin-${arch}/lib/node_modules/npm" "$out_dir/lib/node_modules/npm"
|
||||
# An explicit wrapper, not the dist's bin/npm symlink (packaging may not preserve symlinks): always runs our bundled node + npm-cli.js, no PATH/system-node reliance. run.sh picks it up as $NODE_DIR/npm.
|
||||
cat > "$out_dir/bin/npm" <<'NPMSH'
|
||||
#!/bin/sh
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec "$here/node" "$here/../lib/node_modules/npm/bin/npm-cli.js" "$@"
|
||||
NPMSH
|
||||
chmod +x "$out_dir/bin/npm"
|
||||
rm -rf "$tmp"
|
||||
echo "[3b] Node $NODE_VERSION ($arch) staged ($(du -h "$out_dir/bin/node" | cut -f1))"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user