diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index f2ef9988..14d633d4 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -22,3 +22,4 @@ class AppSettings(BaseModel):
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
auto_select_mode_on_new_agent: bool = False
+ expand_new_chats_in_dashboard: bool = False
diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py
index 274f077c..2d7c8bba 100644
--- a/backend/apps/tools_lib/tools_lib.py
+++ b/backend/apps/tools_lib/tools_lib.py
@@ -382,6 +382,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
config["command"] = resolved
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
+ env.setdefault("PYTHONPATH", "")
return config
@@ -548,6 +549,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
raise HTTPException(status_code=400, detail=f"Command '{command}' not found on PATH or common install locations")
proc_env = {**os.environ, **(env or {}), "PATH": _augmented_path()}
+ proc_env.pop("PYTHONPATH", None)
proc = await asyncio.create_subprocess_exec(
cmd_path, *(args or []),
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index 2d4edc02..2e1ccb79 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -27,6 +27,9 @@ import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
+import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
+import CloseIcon from '@mui/icons-material/Close';
+import LinearProgress from '@mui/material/LinearProgress';
import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -38,6 +41,7 @@ const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 400;
const SIDEBAR_DEFAULT = 220;
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
+const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
const CUSTOMIZATION_ITEMS = [
{ label: 'Prompts', path: '/templates', icon: },
@@ -75,10 +79,34 @@ const AppShell: React.FC = () => {
const updateStatus = useAppSelector((state) => state.update.status);
const availableVersion = useAppSelector((state) => state.update.availableVersion);
- const [updateBannerDismissed, setUpdateBannerDismissed] = useState(false);
+ const downloadPercent = useAppSelector((state) => state.update.downloadPercent);
- const showUpdateDot = updateStatus === 'available' || updateStatus === 'downloaded';
- const showUpdateBanner = updateStatus === 'downloaded' && !updateBannerDismissed;
+ const [dismissedVersion, setDismissedVersion] = useState(() => {
+ try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; }
+ });
+ const [snackbarDismissed, setSnackbarDismissed] = useState(false);
+
+ const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
+ const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading';
+
+ const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion;
+ const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion;
+ const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed;
+
+ const handleDismissBanner = useCallback(() => {
+ if (availableVersion) {
+ try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {}
+ setDismissedVersion(availableVersion);
+ }
+ }, [availableVersion]);
+
+ const handleDownloadUpdate = useCallback(async () => {
+ try { await (window as any).openswarm?.downloadUpdate(); } catch {}
+ }, []);
+
+ const handleInstallUpdate = useCallback(() => {
+ (window as any).openswarm?.installUpdate();
+ }, []);
const dashboardItems = useAppSelector((state) => state.dashboards.items);
const dashboardList = Object.values(dashboardItems).sort(
@@ -279,6 +307,98 @@ const AppShell: React.FC = () => {
+ {showUpdateBanner && (
+
+
+
+ {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
+ {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`}
+ {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`}
+
+ {updateStatus === 'downloading' && (
+
+ )}
+ {updateStatus === 'downloading' && (
+
+ {Math.round(downloadPercent)}%
+
+ )}
+ {updateStatus === 'available' && (
+
+ )}
+ {updateStatus === 'downloaded' && (
+
+ )}
+
+
+
+
+ )}
+
{!sidebarCollapsed && (
<>
@@ -735,36 +855,60 @@ const AppShell: React.FC = () => {
setSnackbarDismissed(true)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
}
+ icon={updateStatus === 'downloaded'
+ ?
+ :
+ }
action={
-
+ {updateStatus === 'available' && (
+
+ )}
+ {updateStatus === 'downloaded' && (
+
+ )}
}
sx={{
@@ -775,7 +919,8 @@ const AppShell: React.FC = () => {
'& .MuiAlert-icon': { color: c.accent.primary },
}}
>
- OpenSwarm {availableVersion} downloaded — restart to update
+ {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
+ {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded — restart to update`}
diff --git a/frontend/src/app/components/useDomElementSelector.ts b/frontend/src/app/components/useDomElementSelector.ts
index b4535e19..8115769b 100644
--- a/frontend/src/app/components/useDomElementSelector.ts
+++ b/frontend/src/app/components/useDomElementSelector.ts
@@ -120,6 +120,7 @@ export function useDomElementSelector(): DomSelectorState {
const dragOriginRef = useRef<{ x: number; y: number } | null>(null);
const isDraggingRef = useRef(false);
const dragBoundsRef = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
+ const preDragFocusRef = useRef(null);
const excludeIdRef = useRef(null);
useEffect(() => {
@@ -245,8 +246,11 @@ export function useDomElementSelector(): DomSelectorState {
if (e.button !== 0) return;
if (e.metaKey || e.ctrlKey) return;
const target = e.target as Element;
- // Only start drag on "empty" canvas areas (not on selectable elements)
- if (target && findSelectableAncestor(target, excludeIdRef.current)) return;
+ if (target && findSelectableAncestor(target, excludeIdRef.current)) {
+ e.preventDefault();
+ return;
+ }
+ preDragFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dragOriginRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
}, []);
@@ -291,12 +295,17 @@ export function useDomElementSelector(): DomSelectorState {
});
}
+ const wasDragging = isDraggingRef.current;
dragOriginRef.current = null;
isDraggingRef.current = false;
dragBoundsRef.current = null;
setDragRect(EMPTY_DRAG);
setDragPreview([]);
if (dragPreviewRafRef.current) cancelAnimationFrame(dragPreviewRafRef.current);
+ if (wasDragging && preDragFocusRef.current) {
+ preDragFocusRef.current.focus();
+ }
+ preDragFocusRef.current = null;
}, [ctx]);
const handleClick = useCallback((e: MouseEvent) => {
@@ -327,6 +336,7 @@ export function useDomElementSelector(): DomSelectorState {
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
+ preDragFocusRef.current = null;
return;
}
@@ -353,6 +363,7 @@ export function useDomElementSelector(): DomSelectorState {
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
+ preDragFocusRef.current = null;
};
}, [ctx?.selectMode, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]);
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index ff028b5d..525bd254 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -981,6 +981,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
e.preventDefault()}
onClick={() => {
if (!elementSelection.selectMode && sessionId) {
elementSelection.setExcludeSelectId(sessionId);
diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx
index 79c62974..9d597b05 100644
--- a/frontend/src/app/pages/Dashboard/Dashboard.tsx
+++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx
@@ -84,6 +84,7 @@ const DashboardInner: React.FC = () => {
const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity);
const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut);
const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
+ const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard);
const outputs = useAppSelector((state) => state.outputs.items);
const sessionList = Object.values(sessions);
@@ -420,7 +421,7 @@ const DashboardInner: React.FC = () => {
contextPaths: contextPaths?.map((cp) => ({ path: cp.path, type: cp.type })),
forcedTools,
attachedSkills,
- expand: false,
+ expand: expandNewChats,
}),
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
@@ -452,7 +453,7 @@ const DashboardInner: React.FC = () => {
}
});
},
- [canvas.viewportRef, dispatch, dashboardId],
+ [canvas.viewportRef, dispatch, dashboardId, expandNewChats],
);
const handleAddView = useCallback((outputId: string) => {
diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx
index 58d71b10..bae49d38 100644
--- a/frontend/src/app/pages/Settings/Settings.tsx
+++ b/frontend/src/app/pages/Settings/Settings.tsx
@@ -554,7 +554,7 @@ const Settings: React.FC = () => {
-
+
Auto-enable element selection
Automatically enter element selection mode when creating a new agent.
@@ -569,6 +569,21 @@ const Settings: React.FC = () => {
/>
+
+
+ Default agent spawn state in dashboard
+ When enabled, new agents spawn expanded instead of collapsed.
+
+ setForm({ ...form, expand_new_chats_in_dashboard: e.target.checked })}
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
+ }}
+ />
+
+
{/* ── Browser ── */}
Browser
diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts
index 4530871c..0d7acce8 100644
--- a/frontend/src/shared/state/settingsSlice.ts
+++ b/frontend/src/shared/state/settingsSlice.ts
@@ -23,6 +23,7 @@ export interface AppSettings {
anthropic_api_key: string | null;
browser_homepage: string;
auto_select_mode_on_new_agent: boolean;
+ expand_new_chats_in_dashboard: boolean;
}
export interface BrowseResult {
@@ -52,6 +53,7 @@ const initialState: SettingsState = {
anthropic_api_key: null,
browser_homepage: 'https://www.google.com',
auto_select_mode_on_new_agent: false,
+ expand_new_chats_in_dashboard: false,
},
loading: false,
loaded: false,