diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index e660290c..9a9c3818 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -424,17 +424,95 @@ async def duplicate_dashboard(dashboard_id: str): new_id = uuid4().hex now = datetime.now().isoformat() + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.manager.session.session_store import _save_session + + source_layout = source_data.get("layout", {}) or {} + source_browser_cards = source_layout.get("browser_cards", {}) or {} + source_cards = source_layout.get("cards", {}) or {} + + browser_id_remap: dict[str, str] = {old_bid: uuid4().hex for old_bid in source_browser_cards.keys()} + new_browser_cards: dict[str, dict] = {} + for old_bid, card in source_browser_cards.items(): + new_bid = browser_id_remap[old_bid] + new_card = {**card, "browser_id": new_bid} + new_browser_cards[new_bid] = new_card + + candidate_ids: set[str] = set() + for sid, sess in agent_manager.sessions.items(): + if getattr(sess, "dashboard_id", None) == dashboard_id: + candidate_ids.add(sid) + if os.path.exists(SESSIONS_DIR): + for fname in os.listdir(SESSIONS_DIR): + if not fname.endswith(".json"): + continue + data = read_json_or_none(os.path.join(SESSIONS_DIR, fname)) + if data and data.get("dashboard_id") == dashboard_id: + candidate_ids.add(fname[:-5]) + + session_id_remap: dict[str, str] = {} + duplicated_sessions = [] # (old_id, new_session) + for old_sid in candidate_ids: + try: + new_sess = await agent_manager.duplicate_session(old_sid, dashboard_id=new_id) + except Exception: + logger.warning(f"Failed to duplicate session {old_sid} during dashboard duplication", exc_info=True) + continue + session_id_remap[old_sid] = new_sess.id + duplicated_sessions.append((old_sid, new_sess)) + + for old_sid, new_sess in duplicated_sessions: + source_sess = agent_manager.sessions.get(old_sid) + old_browser_id = getattr(source_sess, "browser_id", None) if source_sess else None + old_parent_sid = getattr(source_sess, "parent_session_id", None) if source_sess else None + if old_browser_id is None or old_parent_sid is None: + data = read_json_or_none(os.path.join(SESSIONS_DIR, f"{old_sid}.json")) or {} + if old_browser_id is None: + old_browser_id = data.get("browser_id") + if old_parent_sid is None: + old_parent_sid = data.get("parent_session_id") + if old_browser_id and old_browser_id in browser_id_remap: + new_sess.browser_id = browser_id_remap[old_browser_id] + if old_parent_sid and old_parent_sid in session_id_remap: + new_sess.parent_session_id = session_id_remap[old_parent_sid] + _save_session(new_sess.id, new_sess.model_dump(mode="json")) + + new_cards: dict[str, dict] = {} + for old_sid, card in source_cards.items(): + new_sid = session_id_remap.get(old_sid) + if not new_sid: + continue + new_cards[new_sid] = {**card, "session_id": new_sid} + + for new_card in new_browser_cards.values(): + old_spawn = new_card.get("spawned_by") + if old_spawn and old_spawn in session_id_remap: + new_card["spawned_by"] = session_id_remap[old_spawn] + elif old_spawn: + new_card["spawned_by"] = None + + new_expanded = [ + session_id_remap[sid] + for sid in source_layout.get("expanded_session_ids", []) or [] + if sid in session_id_remap + ] + + new_layout = { + **source_layout, + "cards": new_cards, + "view_cards": source_layout.get("view_cards", {}) or {}, + "browser_cards": new_browser_cards, + "notes": source_layout.get("notes", {}) or {}, + "expanded_session_ids": new_expanded, + } + new_dashboard = { **source_data, "id": new_id, "name": f"{source_data.get('name', 'Untitled')} (copy)", "created_at": now, "updated_at": now, - "layout": { - "cards": {}, - "view_cards": source_data.get("layout", {}).get("view_cards", {}), - "browser_cards": source_data.get("layout", {}).get("browser_cards", {}), - }, + "layout": new_layout, } atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard) diff --git a/electron/package-lock.json b/electron/package-lock.json index 9a47efdc..96e0bad9 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.1.70", + "version": "1.2.77", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.1.70", + "version": "1.2.77", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", @@ -567,6 +567,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1434,6 +1435,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -1582,6 +1584,7 @@ "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -2885,6 +2888,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/electron/webview-preload.js b/electron/webview-preload.js index 421ec2c8..ab4aa519 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -177,6 +177,35 @@ try { window.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); + // --------------------------------------------------------------------------- + // Double-click to fit the browser card (parity with agent-chat dblclick). + // + // A is an out-of-process guest, so a dblclick inside it never + // reaches the embedding renderer and the dashboard's card dblclick-to-fit + // never fires over page content. Forward non-interactive dblclicks to the + // host (BrowserCard's ipc-message handler calls onDoubleClick -> fitToCards). + // We never preventDefault: the page keeps its native behavior. We skip + // interactive targets and skip when the dblclick selected a word, so links/ + // buttons/inputs and native word-select win there. canvas is treated as + // interactive on purpose (maps/games/design tools use dblclick meaningfully). + const INTERACTIVE_DBLCLICK_SELECTOR = [ + 'a[href]', 'button', 'input', 'textarea', 'select', 'option', 'label', + 'summary', 'details', 'video', 'audio', 'iframe', 'embed', 'object', 'canvas', + '[contenteditable=""]', '[contenteditable="true"]', + '[role="button"]', '[role="link"]', '[role="textbox"]', '[role="menuitem"]', + '[role="tab"]', '[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="slider"]', + ].join(','); + const onDblClickCapture = (e) => { + try { + const t = e.target; + if (t && t.closest && t.closest(INTERACTIVE_DBLCLICK_SELECTOR)) return; + const sel = window.getSelection && window.getSelection(); + if (sel && String(sel).trim().length > 0) return; + ipcRenderer.sendToHost('browser-dblclick'); + } catch (_) {} + }; + document.addEventListener('dblclick', onDblClickCapture, { capture: true }); + // --------------------------------------------------------------------------- // [FRONTEND] console capture for the App Builder Terminal pane. // diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92d511d7..8e97f035 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,6 +84,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1964,6 +1965,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2007,6 +2009,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2242,6 +2245,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.10", @@ -3374,6 +3378,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3770,6 +3775,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3809,6 +3815,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4137,6 +4144,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8184,6 +8192,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8240,6 +8249,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8458,6 +8468,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8470,6 +8481,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8516,6 +8528,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8654,7 +8667,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8966,6 +8980,7 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10069,6 +10084,7 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10117,6 +10133,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index aa898111..0c9ba672 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -78,6 +78,13 @@ const AppShell: React.FC = () => { return fn as typeof navigateRaw; }, [navigateRaw]); const location = useLocation(); + // React Router (HashRouter) stores a monotonic index in history state. location + // re-renders on every nav, by which point window.history.state.idx is updated. + const historyIdx = (window.history.state?.idx as number | undefined) ?? 0; + const maxHistoryIdx = useRef(0); + maxHistoryIdx.current = Math.max(maxHistoryIdx.current, historyIdx); + const canGoBack = historyIdx > 0; + const canGoForward = historyIdx < maxHistoryIdx.current; const [dashboardsExpanded, setDashboardsExpanded] = useState(true); const [appsExpanded, setAppsExpanded] = useState(true); // Collapsed by default: config rows are progressive disclosure, not daily nav. Onboarding reads data-expanded and clicks to open when it needs them. @@ -401,38 +408,46 @@ const AppShell: React.FC = () => { - navigate(-1)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' }, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - '&:hover svg': { transform: 'translateX(-2px)' }, - }} - > - - + {/* span wrapper so a disabled button still shows its Tooltip; lucide + glyph + hover-slide kept from the redesign, disabled-state from #68. */} + + navigate(-1)} + disabled={!canGoBack} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' }, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + '&:hover svg': { transform: 'translateX(-2px)' }, + }} + > + + + - navigate(1)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' }, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - '&:hover svg': { transform: 'translateX(2px)' }, - }} - > - - + + navigate(1)} + disabled={!canGoForward} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' }, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + '&:hover svg': { transform: 'translateX(2px)' }, + }} + > + + + diff --git a/frontend/src/app/components/editor/SelectionOverlay.tsx b/frontend/src/app/components/editor/SelectionOverlay.tsx index 51e534e4..54da1d50 100644 --- a/frontend/src/app/components/editor/SelectionOverlay.tsx +++ b/frontend/src/app/components/editor/SelectionOverlay.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; import ReactDOM from 'react-dom'; -import { OverlayState, DragRect, DragPreviewElement } from './useDomElementSelector'; +import { OverlayState, DragRect, DragPreviewElement, clipRectToAncestors } from './useDomElementSelector'; import { useElementSelection } from './ElementSelectionContext'; const HIGHLIGHT_COLOR = '#3b82f6'; @@ -20,6 +20,8 @@ interface PersistentRect { width: number; height: number; label: string; + clipLeft: number; + clipTop: number; } interface Props { @@ -52,13 +54,17 @@ const SelectionOverlay: React.FC = ({ overlay, dragRect, dragPreview = [] const domEl = document.querySelector(sel.selectorPath); if (domEl) { const rect = domEl.getBoundingClientRect(); + const clipped = clipRectToAncestors(domEl, rect); + if (clipped.hidden) continue; rects.push({ id: sel.id, - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, + top: clipped.top, + left: clipped.left, + width: clipped.width, + height: clipped.height, label: sel.semanticLabel || sel.tagName, + clipLeft: clipped.clipLeft, + clipTop: clipped.clipTop, }); } } catch { @@ -105,8 +111,8 @@ const SelectionOverlay: React.FC = ({ overlay, dragRect, dragPreview = []
= ({ overlay, dragRect, dragPreview = []
= ({ overlay, dragRect, dragPreview = []
= { @@ -68,6 +71,50 @@ function rectsIntersect( return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } +export interface ClippedRect { + top: number; + left: number; + width: number; + height: number; + hidden: boolean; + clipLeft: number; + clipTop: number; +} + +export function clipRectToAncestors(el: Element, raw: DOMRect): ClippedRect { + let left = raw.left; + let top = raw.top; + let right = raw.right; + let bottom = raw.bottom; + let clipLeft = -Infinity; + let clipTop = -Infinity; + let p: Element | null = el.parentElement; + while (p && p !== document.body && p !== document.documentElement) { + const s = getComputedStyle(p); + if (s.overflowX !== 'visible' || s.overflowY !== 'visible') { + const r = p.getBoundingClientRect(); + left = Math.max(left, r.left); + top = Math.max(top, r.top); + right = Math.min(right, r.right); + bottom = Math.min(bottom, r.bottom); + clipLeft = Math.max(clipLeft, r.left); + clipTop = Math.max(clipTop, r.top); + } + p = p.parentElement; + } + const width = right - left; + const height = bottom - top; + return { + top, + left, + width, + height, + hidden: width <= 0 || height <= 0, + clipLeft: clipLeft === -Infinity ? raw.left : clipLeft, + clipTop: clipTop === -Infinity ? raw.top : clipTop, + }; +} + function buildSelectedElement(el: Element): SelectedElement { const type = el.getAttribute(SELECT_ATTR) || ''; const selectId = el.getAttribute(SELECT_ID_ATTR) || ''; @@ -98,6 +145,8 @@ export interface DragPreviewElement { height: number; label: string; action: 'add' | 'remove'; + clipLeft: number; + clipTop: number; } const DRAG_THRESHOLD = 5; @@ -110,6 +159,7 @@ export interface DomSelectorState { export function useDomElementSelector(): DomSelectorState { const ctx = useElementSelection(); + const active = useDashboardActive(); const [overlay, setOverlay] = useState(EMPTY_OVERLAY); const [dragRect, setDragRect] = useState(EMPTY_DRAG); const [dragPreview, setDragPreview] = useState([]); @@ -180,18 +230,22 @@ export function useDomElementSelector(): DomSelectorState { const rect = el.getBoundingClientRect(); if (rectsIntersect(b, { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom })) { if (seen.has(selectId)) return; + const clipped = clipRectToAncestors(el, rect); + if (clipped.hidden) return; seen.add(selectId); const type = el.getAttribute(SELECT_ATTR) || ''; let meta: Record = {}; try { meta = JSON.parse(el.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} preview.push({ selectId, - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, + top: clipped.top, + left: clipped.left, + width: clipped.width, + height: clipped.height, label: buildSemanticLabel(type, meta), action: selectedIdsRef.current.has(selectId) ? 'remove' : 'add', + clipLeft: clipped.clipLeft, + clipTop: clipped.clipTop, }); } }); @@ -226,17 +280,24 @@ export function useDomElementSelector(): DomSelectorState { if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { const rect = selectable.getBoundingClientRect(); + const clipped = clipRectToAncestors(selectable, rect); + if (clipped.hidden) { + setOverlay(EMPTY_OVERLAY); + return; + } const type = selectable.getAttribute(SELECT_ATTR) || ''; let meta: Record = {}; try { meta = JSON.parse(selectable.getAttribute(SELECT_META_ATTR) || '{}'); } catch {} const label = buildSemanticLabel(type, meta); setOverlay({ visible: true, - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, + top: clipped.top, + left: clipped.left, + width: clipped.width, + height: clipped.height, label, + clipLeft: clipped.clipLeft, + clipTop: clipped.clipTop, }); }); }, []); @@ -330,7 +391,7 @@ export function useDomElementSelector(): DomSelectorState { }, [ctx]); useEffect(() => { - if (!ctx?.selectMode) { + if (!ctx?.selectMode || !active) { setOverlay(EMPTY_OVERLAY); setDragRect(EMPTY_DRAG); setDragPreview([]); @@ -369,7 +430,7 @@ export function useDomElementSelector(): DomSelectorState { // Defensive: if select mode flips off mid-drag, drop the class so webviews regain interactivity. document.body.classList.remove('dashboard-marquee-active'); }; - }, [ctx?.selectMode, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]); + }, [ctx?.selectMode, active, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]); return { overlay, dragRect, dragPreview }; } diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 35b47bc2..d94cc0b9 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -2,11 +2,14 @@ import React from 'react'; import SelectionOverlay from '@/app/components/editor/SelectionOverlay'; import { ElementSelectionProvider } from '@/app/components/editor/ElementSelectionContext'; import { useDomElementSelector } from '@/app/components/editor/useDomElementSelector'; +import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useDashboardController } from './hooks/state/useDashboardController'; import DashboardCanvas from './canvas/DashboardCanvas'; const DashboardSelectionOverlay: React.FC = () => { + const active = useDashboardActive(); const { overlay, dragRect, dragPreview } = useDomElementSelector(); + if (!active) return null; return ; }; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 567b25ca..1b217b06 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -192,6 +192,10 @@ const BrowserCard: React.FC = ({ }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); + // Read via ref inside the webview-attach effect so a new onDoubleClick identity + // doesn't re-run that effect (which would re-register the webview). + const onDoubleClickRef = useRef(onDoubleClick); + onDoubleClickRef.current = onDoubleClick; const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); const elementSelectionCtx = useElementSelection(); @@ -308,6 +312,8 @@ const BrowserCard: React.FC = ({ // No unconditional log; forwarded webview-console messages were causing 100s of host warns/sec and main-thread stalls. if (e?.channel === 'passkey-detected') { setPasskeyDialogOpen(true); + } else if (e?.channel === 'browser-dblclick') { + onDoubleClickRef.current?.(browserId, 'browser'); } else if (e?.channel === 'canvas-wheel-zoom') { // Convert guest coords to doc coords and dispatch a CustomEvent; synthetic WheelEvent bubble was unreliable through GuestView. const payload = e.args?.[0] || {}; @@ -1373,7 +1379,7 @@ const BrowserCard: React.FC = ({ position: 'absolute', cursor: CURSOR_MAP[dir], opacity: 0, - zIndex: 10, + zIndex: 20, ...sx, }} /> diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index f0644826..0a8650f2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useAppSelector } from '@/shared/hooks'; // All of the dashboard's Redux reads in one place. Keeps Dashboard.tsx a @@ -10,7 +11,19 @@ export function useDashboardSelectors(dashboardId: string) { const expandedSessionIds = useAppSelector((state) => state.agents.expandedSessionIds); const cards = useAppSelector((state) => state.dashboardLayout.cards); const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); - const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + const allBrowserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + // Browser cards live in a single global dict (no per-dashboard nesting) so + // a card spawned on dashboard A used to leak into dashboard B if the user + // switched mid-spawn. Filter here so every downstream consumer (render, + // bounds, layout save, keyboard nav) sees only this dashboard's cards. + // Legacy cards without dashboard_id fall through , next save tags them. + const browserCards = 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 notes = useAppSelector((state) => state.dashboardLayout.notes); const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 8f020b28..1a9d097f 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -59,6 +59,8 @@ export interface BrowserCardPosition { zOrder: number; /** Agent session that spawned this browser; auto-removed when its owner reaches terminal state. */ spawned_by?: string | null; + /** Dashboard this card belongs to; cards render and persist only on their owning dashboard. */ + dashboard_id?: string; } export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray'; @@ -975,10 +977,14 @@ const dashboardLayoutSlice = createSlice({ // browsers spawn). The caller says which; never inferred from state. const isReconnectRefetch = action.meta.arg.isReconnect === true; state.initialized = true; + const ownerDashboardId = action.meta.arg.dashboardId; 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; + } 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. @@ -992,6 +998,9 @@ const dashboardLayoutSlice = createSlice({ addMissingCards(state.cards, action.payload.cards, occupied); addMissingCards(state.viewCards, action.payload.viewCards, occupied); addMissingCards(state.browserCards, action.payload.browserCards, occupied); + for (const card of Object.values(state.browserCards)) { + if (!card.dashboard_id) card.dashboard_id = ownerDashboardId; + } addMissingCards(state.notes, action.payload.notes || {}, occupied); } state.persistedExpandedSessionIds = action.payload.expandedSessionIds; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 5156d085..cd228599 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -736,7 +736,14 @@ class WebSocketManager { case 'dashboard:browser_card_added': if (data.browser_card) { - store.dispatch(addBrowserCardFromBackend(data.browser_card)); + // Tag with origin dashboard so the card renders only on the dashboard + // that spawned it , without this, a browser spawned by an agent on + // dashboard A leaks into whatever dashboard the user is currently + // viewing (the global browserCards dict + unfiltered render). + store.dispatch(addBrowserCardFromBackend({ + ...data.browser_card, + dashboard_id: data.dashboard_id, + })); const parentId = data.parent_session_id; if (parentId) { const layoutState = store.getState().dashboardLayout;