Merge PR #68 [aidan] bug: ui/ux fixes (back/forward disabled-state, browser-card dashboard scoping + resize, selection-overlay clipping, dashboard-duplicate carries chats)

AppShell conflict resolved: kept the lucide/redesign + folded in the back/forward disabled-state logic.
This commit is contained in:
ciregenz
2026-06-11 02:59:30 -07:00
12 changed files with 311 additions and 63 deletions
+83 -5
View File
@@ -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)
+6 -2
View File
@@ -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"
},
+29
View File
@@ -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 <webview> 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.
//
+18 -1
View File
@@ -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",
+45 -30
View File
@@ -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 = () => {
</IconButton>
</Tooltip>
<Tooltip title="Back">
<IconButton
size="small"
onClick={() => 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)' },
}}
>
<ArrowLeft size={18} />
</IconButton>
{/* span wrapper so a disabled button still shows its Tooltip; lucide
glyph + hover-slide kept from the redesign, disabled-state from #68. */}
<span>
<IconButton
size="small"
onClick={() => 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)' },
}}
>
<ArrowLeft size={18} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Forward">
<IconButton
size="small"
onClick={() => 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)' },
}}
>
<ArrowRight size={18} />
</IconButton>
<span>
<IconButton
size="small"
onClick={() => 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)' },
}}
>
<ArrowRight size={18} />
</IconButton>
</span>
</Tooltip>
<DynamicIsland />
@@ -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<Props> = ({ 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<Props> = ({ overlay, dragRect, dragPreview = []
<div
style={{
position: 'fixed',
top: Math.max(0, r.top),
left: Math.max(0, r.left),
top: Math.max(r.clipTop, r.top),
left: Math.max(r.clipLeft, r.left),
background: HIGHLIGHT_COLOR,
color: '#fff',
fontSize: 9,
@@ -171,8 +177,8 @@ const SelectionOverlay: React.FC<Props> = ({ overlay, dragRect, dragPreview = []
<div
style={{
position: 'fixed',
top: Math.max(0, p.top),
left: Math.max(0, p.left),
top: Math.max(p.clipTop, p.top),
left: Math.max(p.clipLeft, p.left),
background: labelBg,
color: '#fff',
fontSize: 9,
@@ -214,8 +220,8 @@ const SelectionOverlay: React.FC<Props> = ({ overlay, dragRect, dragPreview = []
<div
style={{
position: 'fixed',
top: Math.max(0, overlay.top),
left: Math.max(0, overlay.left),
top: Math.max(overlay.clipTop, overlay.top),
left: Math.max(overlay.clipLeft, overlay.left),
background: HIGHLIGHT_COLOR,
color: '#fff',
fontSize: 10,
@@ -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';
@@ -15,6 +16,8 @@ export interface OverlayState {
width: number;
height: number;
label: string;
clipLeft: number;
clipTop: number;
}
export interface DragRect {
@@ -25,7 +28,7 @@ export interface DragRect {
height: number;
}
const EMPTY_OVERLAY: OverlayState = { visible: false, top: 0, left: 0, width: 0, height: 0, label: '' };
const EMPTY_OVERLAY: OverlayState = { visible: false, top: 0, left: 0, width: 0, height: 0, label: '', clipLeft: 0, clipTop: 0 };
const EMPTY_DRAG: DragRect = { visible: false, top: 0, left: 0, width: 0, height: 0 };
const SEMANTIC_LABELS: Record<string, string> = {
@@ -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<OverlayState>(EMPTY_OVERLAY);
const [dragRect, setDragRect] = useState<DragRect>(EMPTY_DRAG);
const [dragPreview, setDragPreview] = useState<DragPreviewElement[]>([]);
@@ -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<string, any> = {};
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<string, any> = {};
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 };
}
@@ -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 <SelectionOverlay overlay={overlay} dragRect={dragRect} dragPreview={dragPreview} />;
};
@@ -192,6 +192,10 @@ const BrowserCard: React.FC<Props> = ({
}) => {
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<Props> = ({
// 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<Props> = ({
position: 'absolute',
cursor: CURSOR_MAP[dir],
opacity: 0,
zIndex: 10,
zIndex: 20,
...sx,
}}
/>
@@ -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);
@@ -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;
+8 -1
View File
@@ -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;