From ee0cf13b711fa5d3f178dc088be1e8f554839053 Mon Sep 17 00:00:00 2001 From: abccodes Date: Wed, 10 Jun 2026 16:35:08 -0700 Subject: [PATCH 1/7] [aidan] fix: back/foward buttons interaction ui fix --- .../src/app/components/Layout/AppShell.tsx | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 208c85c2..737efd31 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -77,6 +77,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); const [customizationExpanded, setCustomizationExpanded] = useState(true); @@ -400,34 +407,40 @@ const AppShell: React.FC = () => { - navigate(-1)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - }} - > - - + + navigate(-1)} + disabled={!canGoBack} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + }} + > + + + - navigate(1)} - sx={{ - WebkitAppRegion: 'no-drag', - color: c.text.tertiary, - p: 0.5, - borderRadius: 1, - '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, - }} - > - - + + navigate(1)} + disabled={!canGoForward} + sx={{ + WebkitAppRegion: 'no-drag', + color: c.text.tertiary, + p: 0.5, + borderRadius: 1, + '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, + }} + > + + + From ea2f1c36c572d0b0f04a070cb2bd0ae9ed88660b Mon Sep 17 00:00:00 2001 From: Aidan Date: Wed, 10 Jun 2026 17:14:37 -0700 Subject: [PATCH 2/7] fix: stop dashboard select-mode from leaking onto other pages Dashboard stays mounted (visibility-hidden) behind every other route, so its ElementSelectionProvider and portal-rendered SelectionOverlay kept firing on top of the App Builder. Gate both the overlay render and the global mouse listeners in useDomElementSelector on useDashboardActive, leaving selection state untouched so it restores on return. --- frontend/src/app/components/editor/useDomElementSelector.ts | 6 ++++-- frontend/src/app/pages/Dashboard/Dashboard.tsx | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/editor/useDomElementSelector.ts b/frontend/src/app/components/editor/useDomElementSelector.ts index 6107e8b9..43eba97a 100644 --- a/frontend/src/app/components/editor/useDomElementSelector.ts +++ b/frontend/src/app/components/editor/useDomElementSelector.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { SelectedElement, useElementSelection } from './ElementSelectionContext'; +import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; const SELECT_ATTR = 'data-select-type'; const SELECT_ID_ATTR = 'data-select-id'; @@ -110,6 +111,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([]); @@ -330,7 +332,7 @@ export function useDomElementSelector(): DomSelectorState { }, [ctx]); useEffect(() => { - if (!ctx?.selectMode) { + if (!ctx?.selectMode || !active) { setOverlay(EMPTY_OVERLAY); setDragRect(EMPTY_DRAG); setDragPreview([]); @@ -369,7 +371,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 ; }; From ded951b8a0d531a6e57222429ff1e356e960e95e Mon Sep 17 00:00:00 2001 From: Aidan Date: Wed, 10 Jun 2026 17:37:04 -0700 Subject: [PATCH 3/7] fix: clip selection overlay to the chat scroller for tall messages SelectionOverlay portals to document.body and drew highlights at the raw getBoundingClientRect() of the selected element, so a tall message bubble let the blue overlay bleed past the chat panel onto the rest of the UI. Walk up every overflow-clipping ancestor, intersect the rect with each, and skip rendering when fully out of view. Applied to hover, persistent, and drag-preview rects, with labels clamped to the clip top/left so they never paint above the chat container. --- .../components/editor/SelectionOverlay.tsx | 28 ++++--- .../editor/useDomElementSelector.ts | 77 ++++++++++++++++--- 2 files changed, 85 insertions(+), 20 deletions(-) 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 = []
= { @@ -69,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) || ''; @@ -99,6 +145,8 @@ export interface DragPreviewElement { height: number; label: string; action: 'add' | 'remove'; + clipLeft: number; + clipTop: number; } const DRAG_THRESHOLD = 5; @@ -182,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, }); } }); @@ -228,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, }); }); }, []); From 4caceab9271a85b8ae394d3092a05b5f2437760d Mon Sep 17 00:00:00 2001 From: abccodes Date: Wed, 10 Jun 2026 18:07:35 -0700 Subject: [PATCH 4/7] [aidan] fix: selector doesnt go outside of chat box --- electron/webview-preload.js | 29 +++++++++++++++++++ .../app/pages/Dashboard/cards/BrowserCard.tsx | 6 ++++ 2 files changed, 35 insertions(+) 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/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index f21d1ce4..31862f43 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] || {}; From b3f91408fc781c728b0c35d602ea35ed44353c4c Mon Sep 17 00:00:00 2001 From: Aidan Date: Wed, 10 Jun 2026 23:27:26 -0700 Subject: [PATCH 5/7] [aidan] browser-card: top edge / corners now resize the card, the tab bar was eating the inside 3px of the n/nw/ne handles (zIndex 16 vs 10), so a top grab started a card drag and focused the webview (#71) --- frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 31862f43..78c2c734 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1380,7 +1380,7 @@ const BrowserCard: React.FC = ({ position: 'absolute', cursor: CURSOR_MAP[dir], opacity: 0, - zIndex: 10, + zIndex: 20, ...sx, }} /> From b6db089c2168f6a41b01c463d4c1e34fb6268c74 Mon Sep 17 00:00:00 2001 From: Aidan Date: Thu, 11 Jun 2026 00:29:08 -0700 Subject: [PATCH 6/7] [aidan] fix: scope browser cards to their origin dashboard so they stop leaking across dashboards (#72) The backend already broadcasts the originating dashboard_id with every browser_card_added, but the frontend was throwing it away. Browser cards live in a single global Redux dict and the canvas rendered all of them unfiltered, so a browser spawned by an agent on dashboard A would appear in whatever dashboard the user was currently viewing. Tag every card with its dashboard_id (at WS arrival and at fetchLayout hydration) and filter by it in useDashboardSelectors so render, bounds, layout save, and keyboard nav all see only the local dashboard's cards. --- electron/package-lock.json | 8 ++++++-- frontend/package-lock.json | 19 ++++++++++++++++++- .../hooks/state/useDashboardSelectors.ts | 15 ++++++++++++++- .../src/shared/state/dashboardLayoutSlice.ts | 9 +++++++++ frontend/src/shared/ws/WebSocketManager.ts | 9 ++++++++- 5 files changed, 55 insertions(+), 5 deletions(-) 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/frontend/package-lock.json b/frontend/package-lock.json index d083c637..f596a198 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -83,6 +83,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", @@ -1963,6 +1964,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", @@ -2006,6 +2008,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", @@ -2241,6 +2244,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", @@ -3373,6 +3377,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" @@ -3769,6 +3774,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3808,6 +3814,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4136,6 +4143,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8174,6 +8182,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8230,6 +8239,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8448,6 +8458,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" }, @@ -8460,6 +8471,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" @@ -8506,6 +8518,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" @@ -8644,7 +8657,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", @@ -8956,6 +8970,7 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10059,6 +10074,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", @@ -10107,6 +10123,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/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; From d69854520b1069a66d7a6a54de2e8807506f7abe Mon Sep 17 00:00:00 2001 From: Aidan Date: Thu, 11 Jun 2026 01:16:00 -0700 Subject: [PATCH 7/7] fix: carry agent chats over when duplicating a dashboard (#73) duplicate_dashboard cloned the layout but left every browser_card pointing at the source's browser_id and never copied the sessions, so the new dashboard showed the browser but no chat. Now we mint fresh browser_ids, duplicate every session attached to the source dashboard (in-memory + on-disk), remap browser_id / parent_session_id / spawned_by / cards / expanded_session_ids onto the new ids, and persist via _save_session. Also restores top-level agent cards (layout.cards), which were silently dropped on duplicate. --- backend/apps/dashboards/dashboards.py | 88 +++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) 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)