mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 20:29:56 +02:00
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:
@@ -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)
|
||||
|
||||
|
||||
Generated
+6
-2
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Generated
+18
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user