diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index e6f06c51..32aaeb0c 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -585,6 +585,7 @@ class AgentManager:
options_kwargs = {
"model": session.model,
+ "max_buffer_size": 5 * 1024 * 1024,
"can_use_tool": can_use_tool,
"hooks": {
"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])],
diff --git a/backend/apps/agents/browser_mcp_server.py b/backend/apps/agents/browser_mcp_server.py
index 382178aa..51a12160 100644
--- a/backend/apps/agents/browser_mcp_server.py
+++ b/backend/apps/agents/browser_mcp_server.py
@@ -24,6 +24,11 @@ except ImportError:
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser/command"
+TAB_ID_PROP = {
+ "type": "string",
+ "description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
+}
+
TOOLS = [
{
"name": "BrowserScreenshot",
@@ -38,6 +43,7 @@ TOOLS = [
"type": "string",
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
},
+ "tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
@@ -55,6 +61,7 @@ TOOLS = [
"type": "string",
"description": "The browser card ID.",
},
+ "tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
@@ -69,6 +76,7 @@ TOOLS = [
"type": "string",
"description": "The browser card ID.",
},
+ "tab_id": TAB_ID_PROP,
"url": {
"type": "string",
"description": "The URL to navigate to.",
@@ -89,6 +97,7 @@ TOOLS = [
"type": "string",
"description": "The browser card ID.",
},
+ "tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the element to click.",
@@ -110,6 +119,7 @@ TOOLS = [
"type": "string",
"description": "The browser card ID.",
},
+ "tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the input element.",
@@ -135,6 +145,7 @@ TOOLS = [
"type": "string",
"description": "The browser card ID.",
},
+ "tab_id": TAB_ID_PROP,
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate.",
@@ -164,10 +175,11 @@ def send_notification(method, params=None):
sys.stdout.flush()
-def call_backend(action: str, browser_id: str, params: dict | None = None) -> dict:
+def call_backend(action: str, browser_id: str, params: dict | None = None, tab_id: str = "") -> dict:
payload = json.dumps({
"action": action,
"browser_id": browser_id,
+ "tab_id": tab_id,
"params": params or {},
}).encode()
req = urllib.request.Request(
@@ -186,7 +198,7 @@ def call_backend(action: str, browser_id: str, params: dict | None = None) -> di
return {"error": str(e)}
-MAX_IMAGE_B64_BYTES = 700_000
+MAX_IMAGE_B64_BYTES = 400_000
def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
@@ -196,12 +208,12 @@ def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
try:
raw = base64.b64decode(b64_png)
img = Image.open(BytesIO(raw))
- max_width = 1280
+ max_width = 1024
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
- img.convert("RGB").save(buf, format="JPEG", quality=55)
+ img.convert("RGB").save(buf, format="JPEG", quality=45)
return base64.b64encode(buf.getvalue()).decode(), "image/jpeg"
except Exception:
return None
@@ -209,6 +221,7 @@ def compress_screenshot(b64_png: str) -> tuple[str, str] | None:
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
browser_id = arguments.get("browser_id", "")
+ tab_id = arguments.get("tab_id", "")
if not browser_id:
return {"content": [{"type": "text", "text": "Error: browser_id is required"}], "isError": True}
@@ -224,8 +237,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if not action:
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
- params = {k: v for k, v in arguments.items() if k != "browser_id"}
- result = call_backend(action, browser_id, params)
+ params = {k: v for k, v in arguments.items() if k not in ("browser_id", "tab_id")}
+ result = call_backend(action, browser_id, params, tab_id=tab_id)
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
diff --git a/backend/apps/agents/ws_manager.py b/backend/apps/agents/ws_manager.py
index 22651d8f..8292605c 100644
--- a/backend/apps/agents/ws_manager.py
+++ b/backend/apps/agents/ws_manager.py
@@ -87,7 +87,7 @@ class ConnectionManager:
future.set_result(decision)
async def send_browser_command(
- self, request_id: str, action: str, browser_id: str, params: dict
+ self, request_id: str, action: str, browser_id: str, params: dict, tab_id: str = ""
) -> dict:
"""Send a browser command to the frontend and wait for the result."""
future = asyncio.get_event_loop().create_future()
@@ -97,6 +97,7 @@ class ConnectionManager:
"request_id": request_id,
"action": action,
"browser_id": browser_id,
+ "tab_id": tab_id,
"params": params,
})
diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py
index 9e137e25..29d32af3 100644
--- a/backend/apps/dashboards/dashboards.py
+++ b/backend/apps/dashboards/dashboards.py
@@ -116,6 +116,7 @@ async def list_dashboards():
"auto_named": dumped.get("auto_named", False),
"created_at": dumped.get("created_at"),
"updated_at": dumped.get("updated_at"),
+ "thumbnail": dumped.get("thumbnail"),
})
return {"dashboards": items}
@@ -204,6 +205,8 @@ async def update_dashboard(dashboard_id: str, body: DashboardUpdate):
dashboard.auto_named = False
if body.layout is not None:
dashboard.layout = body.layout
+ if body.thumbnail is not None:
+ dashboard.thumbnail = body.thumbnail
dashboard.updated_at = datetime.now()
_save(dashboard)
return dashboard.model_dump(mode="json")
diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py
index 2d5f6d00..67c7410a 100644
--- a/backend/apps/dashboards/models.py
+++ b/backend/apps/dashboards/models.py
@@ -43,6 +43,7 @@ class Dashboard(BaseModel):
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
layout: DashboardLayout = Field(default_factory=DashboardLayout)
+ thumbnail: Optional[str] = None
class DashboardCreate(BaseModel):
@@ -52,3 +53,4 @@ class DashboardCreate(BaseModel):
class DashboardUpdate(BaseModel):
name: Optional[str] = None
layout: Optional[DashboardLayout] = None
+ thumbnail: Optional[str] = None
diff --git a/backend/apps/modes/modes.py b/backend/apps/modes/modes.py
index dc7b2b81..6d393143 100644
--- a/backend/apps/modes/modes.py
+++ b/backend/apps/modes/modes.py
@@ -15,7 +15,9 @@ from backend.config.paths import MODES_DIR as DATA_DIR
async def modes_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
for builtin in BUILTIN_MODES:
- _save(builtin)
+ path = os.path.join(DATA_DIR, f"{builtin.id}.json")
+ if not os.path.exists(path):
+ _save(builtin)
yield
@@ -85,7 +87,7 @@ async def create_mode(body: ModeCreate):
@modes.router.put("/{mode_id}")
async def update_mode(mode_id: str, body: ModeUpdate):
mode = _load(mode_id)
- for k, v in body.model_dump(exclude_none=True).items():
+ for k, v in body.model_dump(exclude_unset=True).items():
setattr(mode, k, v)
_save(mode)
return {"ok": True, "mode": mode.model_dump()}
diff --git a/backend/main.py b/backend/main.py
index 451e4490..6cd1935f 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -103,13 +103,14 @@ async def browser_command(request: Request):
body = await request.json()
action = body.get("action", "")
browser_id = body.get("browser_id", "")
+ tab_id = body.get("tab_id", "")
params = body.get("params", {})
if not action or not browser_id:
return JSONResponse({"error": "action and browser_id are required"}, status_code=400)
request_id = uuid4().hex
- result = await ws_manager.send_browser_command(request_id, action, browser_id, params)
+ result = await ws_manager.send_browser_command(request_id, action, browser_id, params, tab_id=tab_id)
return JSONResponse(result)
diff --git a/electron/main.js b/electron/main.js
index 8186b728..d5f79a99 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -1,4 +1,4 @@
-const { app, BrowserWindow, ipcMain } = require('electron');
+const { app, BrowserWindow, ipcMain, shell } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const { spawn, execFileSync } = require('child_process');
@@ -316,3 +316,14 @@ ipcMain.handle('download-update', async () => {
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true);
});
+
+ipcMain.handle('capture-page', async (event, rect) => {
+ const image = await event.sender.capturePage(rect || undefined);
+ return image.toDataURL();
+});
+
+ipcMain.handle('open-external', (_event, url) => {
+ if (typeof url === 'string' && /^https?:\/\//.test(url)) {
+ shell.openExternal(url);
+ }
+});
diff --git a/electron/package-lock.json b/electron/package-lock.json
index 5ec26fd8..40b7ace1 100644
--- a/electron/package-lock.json
+++ b/electron/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openswarm",
- "version": "1.0.0",
+ "version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
- "version": "1.0.0",
+ "version": "1.0.2",
"dependencies": {
"electron-updater": "^6.3.0",
"get-port": "^5.1.1"
diff --git a/electron/package.json b/electron/package.json
index 71945ec7..3cdee92b 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "openswarm",
- "version": "1.0.01",
+ "version": "1.0.2",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
diff --git a/electron/preload.js b/electron/preload.js
index d222dadb..2f9037be 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -9,6 +9,8 @@ const { contextBridge, ipcRenderer } = require('electron');
getBackendPort: () => port,
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
+ openExternal: (url) => ipcRenderer.invoke('open-external', url),
+ capturePage: (rect) => ipcRenderer.invoke('capture-page', rect),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index b0027e5e..7628f5f0 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -20,8 +20,8 @@ import Templates from './pages/Templates/Templates';
import Skills from './pages/Skills/Skills';
import Tools from './pages/Tools/Tools';
import Modes from './pages/Modes/Modes';
-import Commands from './pages/Commands/Commands';
import Views from './pages/Views/Views';
+import Customization from './pages/Customization/Customization';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -200,12 +200,13 @@ const ThemedApp: React.FC = () => {
}>
} />
} />
+ } />
} />
} />
- } />
+ } />
} />
- } />
- } />
+ } />
+ } />
diff --git a/frontend/src/app/components/CommandPicker.tsx b/frontend/src/app/components/CommandPicker.tsx
index 32a38bbe..316d51c1 100644
--- a/frontend/src/app/components/CommandPicker.tsx
+++ b/frontend/src/app/components/CommandPicker.tsx
@@ -175,7 +175,7 @@ const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, vi
atItems.push({
id: 'web',
type: 'context' as const,
- category: 'Tools',
+ category: 'Actions',
name: 'Web',
description: 'Search the web and fetch URLs',
command: 'web',
@@ -218,7 +218,7 @@ const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, vi
type: 'context' as const,
category: tool.name,
name: groupName,
- description: `Use all ${groupName} tools`,
+ description: `Use all ${groupName} actions`,
command: groupCmd,
icon: groupIcon,
toolNames: allTools,
@@ -230,7 +230,7 @@ const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, vi
type: 'context' as const,
category: tool.name,
name: svc.name,
- description: `Use ${svc.name} tools from ${tool.name}`,
+ description: `Use ${svc.name} actions from ${tool.name}`,
command: `${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`,
icon: groupIcon,
toolNames: svc.tools,
@@ -244,7 +244,7 @@ const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, vi
type: 'context' as const,
category: tool.name,
name: svc.name,
- description: `Use ${svc.name} tools from ${tool.name}`,
+ description: `Use ${svc.name} actions from ${tool.name}`,
command: svc.name.toLowerCase().replace(/\s+/g, '-'),
icon: groupIcon,
toolNames: svc.tools,
@@ -260,7 +260,7 @@ const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, vi
type: 'context' as const,
category: tool.name,
name: svc.name,
- description: `Use ${svc.name} tools from ${tool.name}`,
+ description: `Use ${svc.name} actions from ${tool.name}`,
command: svc.name.toLowerCase().replace(/\s+/g, '-'),
icon: ,
toolNames: svc.tools,
@@ -274,7 +274,7 @@ const CommandPicker: React.FC = ({ trigger, filter, onSelect, onClose, vi
atItems.push({
id: `view-${out.id}`,
type: 'context' as const,
- category: 'Views',
+ category: 'Apps',
name: out.name,
description: out.description || `Render ${out.name} view`,
command: cmd,
diff --git a/frontend/src/app/components/KeyboardShortcutsHelp.tsx b/frontend/src/app/components/KeyboardShortcutsHelp.tsx
index c634aaf2..1447a635 100644
--- a/frontend/src/app/components/KeyboardShortcutsHelp.tsx
+++ b/frontend/src/app/components/KeyboardShortcutsHelp.tsx
@@ -10,7 +10,7 @@ const shortcuts = [
{ key: 'd', description: 'Go to Dashboard' },
{ key: 't', description: 'Go to Templates' },
{ key: '1-9', description: 'Open agent by position' },
- { key: '⌘M', description: 'Add View' },
+ { key: '⌘M', description: 'Add App' },
{ key: '⌘O', description: 'History' },
{ key: 'Shift+A', description: 'Approve all pending' },
{ key: 'Shift+D', description: 'Deny all pending' },
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index a507caab..ea70a70b 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import Box from '@mui/material/Box';
@@ -17,11 +17,11 @@ import DescriptionIcon from '@mui/icons-material/Description';
import PsychologyIcon from '@mui/icons-material/Psychology';
import BuildIcon from '@mui/icons-material/Build';
import TuneIcon from '@mui/icons-material/Tune';
-import TerminalIcon from '@mui/icons-material/Terminal';
import ViewQuiltIcon from '@mui/icons-material/ViewQuilt';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import AddIcon from '@mui/icons-material/Add';
import SettingsIcon from '@mui/icons-material/Settings';
+import ExtensionIcon from '@mui/icons-material/Extension';
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
@@ -30,25 +30,45 @@ import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchDashboards, createDashboard } from '@/shared/state/dashboardsSlice';
+import { fetchOutputs } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
-const NAV_ITEMS = [
- { label: 'Templates', path: '/templates', icon: },
+const SIDEBAR_MIN = 160;
+const SIDEBAR_MAX = 400;
+const SIDEBAR_DEFAULT = 220;
+const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
+
+const CUSTOMIZATION_ITEMS = [
+ { label: 'Prompts', path: '/templates', icon: },
{ label: 'Skills', path: '/skills', icon: },
- { label: 'Tools', path: '/tools', icon: },
+ { label: 'Actions', path: '/actions', icon: },
{ label: 'Modes', path: '/modes', icon: },
- { label: 'Commands', path: '/commands', icon: },
- { label: 'Views', path: '/views', icon: },
];
+const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
+
+
const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
- const [dashboardsExpanded, setDashboardsExpanded] = useState(false);
+ const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
+ const [appsExpanded, setAppsExpanded] = useState(true);
+ const [customizationExpanded, setCustomizationExpanded] = useState(true);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+ const [sidebarWidth, setSidebarWidth] = useState(() => {
+ try {
+ const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
+ if (stored) {
+ const w = Number(stored);
+ if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w;
+ }
+ } catch {}
+ return SIDEBAR_DEFAULT;
+ });
+ const isResizing = useRef(false);
const updateStatus = useAppSelector((state) => state.update.status);
const availableVersion = useAppSelector((state) => state.update.availableVersion);
@@ -62,14 +82,56 @@ const AppShell: React.FC = () => {
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
);
+ const outputItems = useAppSelector((state) => state.outputs.items);
+ const appsList = Object.values(outputItems).sort(
+ (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
+ );
+
useEffect(() => {
dispatch(fetchDashboards());
+ dispatch(fetchOutputs());
}, [dispatch]);
+ useEffect(() => {
+ try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
+ }, [sidebarWidth]);
+
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ isResizing.current = true;
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+
+ const onMouseMove = (ev: MouseEvent) => {
+ if (!isResizing.current) return;
+ setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX)));
+ };
+
+ const onMouseUp = () => {
+ isResizing.current = false;
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ };
+
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ }, []);
+
+ const handleResizeDoubleClick = useCallback(() => {
+ setSidebarWidth(SIDEBAR_DEFAULT);
+ }, []);
+
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
+ const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/');
+ const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
const activeDashboardId = location.pathname.startsWith('/dashboard/')
? location.pathname.split('/dashboard/')[1]
: null;
+ const activeAppId = location.pathname.startsWith('/apps/')
+ ? location.pathname.split('/apps/')[1]
+ : null;
const handleDashboardsClick = () => {
if (isDashboardRoute && location.pathname === '/') {
@@ -92,6 +154,20 @@ const AppShell: React.FC = () => {
}
};
+ const handleAppsClick = () => {
+ if (isAppsRoute && location.pathname === '/apps') {
+ setAppsExpanded((prev) => !prev);
+ } else {
+ navigate('/apps');
+ setAppsExpanded(true);
+ }
+ };
+
+ const handleCreateApp = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ navigate('/apps/new');
+ };
+
return (
{/* Draggable title bar */}
@@ -188,12 +264,12 @@ const AppShell: React.FC = () => {
{!sidebarCollapsed && (
+ <>
{
{/* Divider */}
- {/* Nav items */}
-
- {NAV_ITEMS.map((item) => (
-
- {({ isActive }) => (
-
+ {
+ if (isCustomizationRoute) {
+ setCustomizationExpanded((prev) => !prev);
+ } else {
+ navigate('/customization');
+ setCustomizationExpanded(true);
+ }
+ }}
+ sx={{
+ borderRadius: 1.5,
+ py: 0.6,
+ px: 1.25,
+ bgcolor: isCustomizationRoute ? `${c.accent.primary}12` : 'transparent',
+ '&:hover': { bgcolor: isCustomizationRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
+ transition: 'background-color 0.15s',
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {CUSTOMIZATION_ITEMS.map((item) => (
+
-
- {React.cloneElement(item.icon, { sx: { fontSize: 20 } })}
-
-
-
- )}
-
- ))}
+ {({ isActive }) => (
+
+
+ {item.label}
+
+
+ )}
+
+ ))}
+
+
+
+ {/* Divider */}
+
+
+ {/* Apps section */}
+
+
+
+
+
+
+
+
+
+
+
+ {appsList.length > 0 && (
+
+ )}
+
+
+ 0} timeout={200}>
+
+ {appsList.map((app) => {
+ const isActive = activeAppId === app.id;
+ return (
+ navigate(`/apps/${app.id}`)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 0.75,
+ pl: 1.25,
+ pr: 1,
+ py: 0.5,
+ ml: '-0.5px',
+ cursor: 'pointer',
+ borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
+ bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
+ '&:hover': { bgcolor: `${c.text.tertiary}0A` },
+ transition: 'background-color 0.12s, border-color 0.12s',
+ }}
+ >
+
+ {app.name}
+
+
+ );
+ })}
+
+
+
+
{/* Settings */}
@@ -403,6 +643,35 @@ const AppShell: React.FC = () => {
+
+ >
)}
diff --git a/frontend/src/app/components/RichPromptEditor.tsx b/frontend/src/app/components/RichPromptEditor.tsx
index 707579b2..ad977949 100644
--- a/frontend/src/app/components/RichPromptEditor.tsx
+++ b/frontend/src/app/components/RichPromptEditor.tsx
@@ -68,7 +68,7 @@ const RichPromptEditor: React.FC = ({
const isLabelFloating = focused || hasContent;
// Sync external value → editor on mount / when value changes externally
- const lastEmittedRef = useRef(value);
+ const lastEmittedRef = useRef(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index 0af2e811..42b93e30 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -327,7 +327,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
'message': 'Message',
'tool-call': 'Tool Call',
'tool-group': 'Tool Group',
- 'view-card': 'View Card',
+ 'view-card': 'App Card',
'browser-card': 'Browser Card',
'dom-element': 'Element',
}[el.semanticType] || el.semanticType;
diff --git a/frontend/src/app/pages/AgentChat/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/MessageBubble.tsx
index be7c43ba..5fbd998e 100644
--- a/frontend/src/app/pages/AgentChat/MessageBubble.tsx
+++ b/frontend/src/app/pages/AgentChat/MessageBubble.tsx
@@ -206,7 +206,7 @@ function buildContextGroups(
key: 'tools',
icon: ,
color: '#f59e0b',
- label: `${forcedTools.length} tool${forcedTools.length > 1 ? 's' : ''} requested`,
+ label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`,
chips: forcedTools.map((t) => ({
label: t,
icon: ,
diff --git a/frontend/src/app/pages/AgentChat/ViewBubble.tsx b/frontend/src/app/pages/AgentChat/ViewBubble.tsx
index 08b06544..ec7b2062 100644
--- a/frontend/src/app/pages/AgentChat/ViewBubble.tsx
+++ b/frontend/src/app/pages/AgentChat/ViewBubble.tsx
@@ -38,7 +38,7 @@ const ViewBubble: React.FC = ({ toolInput, toolResult, isStreaming }) =>
const frontendCode = parsedResult?.frontend_code || (output?.files?.['index.html'] ?? '') || '';
const backendResult = parsedResult?.backend_result || null;
- const outputName = parsedResult?.output_name || output?.name || 'View';
+ const outputName = parsedResult?.output_name || output?.name || 'App';
const outputColor = c.accent.primary;
const outputIcon = output?.icon || 'view_quilt';
const hasPreview = !!frontendCode.trim();
diff --git a/frontend/src/app/pages/Commands/Commands.tsx b/frontend/src/app/pages/Commands/Commands.tsx
index a1bf45dd..49bdcdf6 100644
--- a/frontend/src/app/pages/Commands/Commands.tsx
+++ b/frontend/src/app/pages/Commands/Commands.tsx
@@ -1,7 +1,6 @@
import React, { useEffect, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
-import Paper from '@mui/material/Paper';
import Chip from '@mui/material/Chip';
import DescriptionIcon from '@mui/icons-material/Description';
import PsychologyIcon from '@mui/icons-material/Psychology';
@@ -113,7 +112,7 @@ const SectionHeader: React.FC<{
);
-const Commands: React.FC = () => {
+export const CommandsContent: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const templates = useAppSelector((state) => state.templates.items);
@@ -210,7 +209,7 @@ const Commands: React.FC = () => {
items.push({
prefix: `@${groupCmd}`,
label: groupName,
- description: `Use all ${groupName} tools`,
+ description: `Use all ${groupName} actions`,
icon: groupIcon,
source: tool.name,
});
@@ -218,7 +217,7 @@ const Commands: React.FC = () => {
items.push({
prefix: `@${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`,
label: svc.name,
- description: `Use ${svc.name} tools from ${tool.name}`,
+ description: `Use ${svc.name} actions from ${tool.name}`,
icon: groupIcon,
source: tool.name,
isChild: true,
@@ -229,7 +228,7 @@ const Commands: React.FC = () => {
items.push({
prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`,
label: svc.name,
- description: `Use ${svc.name} tools from ${tool.name}`,
+ description: `Use ${svc.name} actions from ${tool.name}`,
icon: groupIcon,
source: tool.name,
});
@@ -241,7 +240,7 @@ const Commands: React.FC = () => {
items.push({
prefix: `@${svc.name.toLowerCase().replace(/\s+/g, '-')}`,
label: svc.name,
- description: `Use ${svc.name} tools from ${tool.name}`,
+ description: `Use ${svc.name} actions from ${tool.name}`,
icon: ,
source: tool.name,
});
@@ -267,27 +266,9 @@ const Commands: React.FC = () => {
const actionShortcuts = SHORTCUTS.filter((s) => s.category === 'action');
return (
-
-
-
- Commands
-
-
- Manage slash commands, context references, and keyboard shortcuts in one place.
-
-
-
-
+
{/* Slash Commands */}
-
+
}
title="Slash Commands"
@@ -385,22 +366,16 @@ const Commands: React.FC = () => {
))}
)}
-
+
+
+
{/* @ Commands */}
-
+
}
title="@ Context Commands"
- subtitle="Type @ in chat to attach context and activate tools"
+ subtitle="Type @ in chat to attach context and activate actions"
count={atCommands.length}
c={c}
/>
@@ -418,7 +393,7 @@ const Commands: React.FC = () => {
>
- No @ commands yet. Install MCP tools to see them here.
+ No @ commands yet. Install MCP actions to see them here.
) : (
@@ -480,18 +455,12 @@ const Commands: React.FC = () => {
))}
)}
-
+
+
+
{/* Keyboard Shortcuts */}
-
+
}
title="Keyboard Shortcuts"
@@ -579,10 +548,9 @@ const Commands: React.FC = () => {
-
-
+
);
};
-export default Commands;
+export default CommandsContent;
diff --git a/frontend/src/app/pages/Customization/Customization.tsx b/frontend/src/app/pages/Customization/Customization.tsx
new file mode 100644
index 00000000..95a5c362
--- /dev/null
+++ b/frontend/src/app/pages/Customization/Customization.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Card from '@mui/material/Card';
+import CardActionArea from '@mui/material/CardActionArea';
+import DescriptionIcon from '@mui/icons-material/Description';
+import PsychologyIcon from '@mui/icons-material/Psychology';
+import BuildIcon from '@mui/icons-material/Build';
+import TuneIcon from '@mui/icons-material/Tune';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const PANELS = [
+ {
+ label: 'Prompts',
+ path: '/templates',
+ icon: ,
+ description:
+ 'Create and manage reusable prompt templates with structured input fields that your agents can fill in.',
+ },
+ {
+ label: 'Skills',
+ path: '/skills',
+ icon: ,
+ description:
+ 'Install or author reusable skill packages that teach your agents new capabilities and workflows.',
+ },
+ {
+ label: 'Actions',
+ path: '/actions',
+ icon: ,
+ description:
+ 'Define and manage the actions your agents can take.',
+ },
+ {
+ label: 'Modes',
+ path: '/modes',
+ icon: ,
+ description:
+ 'Configure agent interaction modes with custom system prompts, allowed actions, and auto-switching rules.',
+ },
+];
+
+const Customization: React.FC = () => {
+ const c = useClaudeTokens();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+ Customization
+
+
+ Tailor how your agents behave, what they can do, and how they interact.
+
+
+
+
+ {PANELS.map((panel) => (
+
+ navigate(panel.path)}
+ sx={{ p: 3, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1.5 }}
+ >
+
+ {React.cloneElement(panel.icon, { sx: { fontSize: 24 } })}
+
+
+ {panel.label}
+
+
+ {panel.description}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default Customization;
diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx
index e15cd1c7..3bebde74 100644
--- a/frontend/src/app/pages/Dashboard/AgentCard.tsx
+++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx
@@ -791,4 +791,4 @@ const AgentCard: React.FC = ({
);
};
-export default AgentCard;
+export default React.memo(AgentCard);
diff --git a/frontend/src/app/pages/Dashboard/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/BrowserCard.tsx
index 6aa9a756..d2b6727e 100644
--- a/frontend/src/app/pages/Dashboard/BrowserCard.tsx
+++ b/frontend/src/app/pages/Dashboard/BrowserCard.tsx
@@ -11,6 +11,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RefreshIcon from '@mui/icons-material/Refresh';
import CloseIcon from '@mui/icons-material/Close';
+import AddIcon from '@mui/icons-material/Add';
import LockIcon from '@mui/icons-material/Lock';
import SearchIcon from '@mui/icons-material/Search';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
@@ -18,11 +19,23 @@ import {
setBrowserCardPosition,
setBrowserCardSize,
removeBrowserCard,
- updateBrowserCardUrl,
+ addBrowserTab,
+ removeBrowserTab,
+ setActiveBrowserTab,
+ updateBrowserTabUrl,
+ updateBrowserTabTitle,
+ updateBrowserTabFavicon,
+ reorderBrowserTab,
+ type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
-import { registerWebview, unregisterWebview, type BrowserWebview } from '@/shared/browserRegistry';
+import {
+ registerWebview,
+ unregisterWebview,
+ setActiveTab as setRegistryActiveTab,
+ type BrowserWebview,
+} from '@/shared/browserRegistry';
import { useBrowserActivity } from '@/shared/useBrowserActivity';
import { getActionLabel } from '@/shared/browserCommandHandler';
import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl';
@@ -54,9 +67,16 @@ const isElectron = navigator.userAgent.includes('Electron');
type WebviewElement = BrowserWebview;
+interface TabLocalState {
+ loading: boolean;
+ canGoBack: boolean;
+ canGoForward: boolean;
+}
+
interface Props {
browserId: string;
- url: string;
+ tabs: BrowserTab[];
+ activeTabId: string;
cardX: number;
cardY: number;
cardWidth: number;
@@ -72,90 +92,140 @@ interface Props {
const BrowserCard: React.FC = ({
- browserId, url, cardX, cardY, cardWidth, cardHeight, zoom = 1,
+ browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1,
isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
- const webviewRef = useRef(null);
+ const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage);
+
const activity = useBrowserActivity(browserId);
const agentActive = activity.active;
const agentAction = activity.action;
const lastAction = activity.lastAction;
- const [currentUrl, setCurrentUrl] = useState(url);
- const [urlBarValue, setUrlBarValue] = useState(url);
- const [pageTitle, setPageTitle] = useState('');
- const [loading, setLoading] = useState(false);
- const [canGoBack, setCanGoBack] = useState(false);
- const [canGoForward, setCanGoForward] = useState(false);
+ const [tabLocalStates, setTabLocalStates] = useState>({});
+ const updateTabLocal = useCallback((tabId: string, update: Partial) => {
+ setTabLocalStates((prev) => ({
+ ...prev,
+ [tabId]: {
+ loading: false,
+ canGoBack: false,
+ canGoForward: false,
+ ...prev[tabId],
+ ...update,
+ },
+ }));
+ }, []);
- // ---- Webview event wiring ----
+ const activeTab = tabs.find((t) => t.id === activeTabId);
+ const activeUrl = activeTab?.url || '';
+ const activeTitle = activeTab?.title || '';
+ const activeLocal = tabLocalStates[activeTabId] || { loading: false, canGoBack: false, canGoForward: false };
+
+ const [urlBarValue, setUrlBarValue] = useState(activeUrl);
useEffect(() => {
- if (!isElectron) return;
- const wv = webviewRef.current;
- if (!wv) return;
+ setUrlBarValue(activeUrl);
+ }, [activeUrl, activeTabId]);
- const onNavigate = () => {
- const newUrl = wv.getURL();
- setCurrentUrl(newUrl);
- setUrlBarValue(newUrl);
- setCanGoBack(wv.canGoBack());
- setCanGoForward(wv.canGoForward());
- dispatch(updateBrowserCardUrl({ browserId, url: newUrl }));
- };
-
- const onTitleUpdate = () => {
- setPageTitle(wv.getTitle());
- };
-
- const onLoadStart = () => setLoading(true);
- const onLoadStop = () => {
- setLoading(false);
- onNavigate();
- onTitleUpdate();
- };
-
- const onNewWindow = (e: any) => {
- if (e.url) wv.loadURL(e.url);
- };
-
- wv.addEventListener('did-navigate', onNavigate);
- wv.addEventListener('did-navigate-in-page', onNavigate);
- wv.addEventListener('page-title-updated', onTitleUpdate);
- wv.addEventListener('did-start-loading', onLoadStart);
- wv.addEventListener('did-stop-loading', onLoadStop);
- wv.addEventListener('new-window', onNewWindow);
-
- return () => {
- wv.removeEventListener('did-navigate', onNavigate);
- wv.removeEventListener('did-navigate-in-page', onNavigate);
- wv.removeEventListener('page-title-updated', onTitleUpdate);
- wv.removeEventListener('did-start-loading', onLoadStart);
- wv.removeEventListener('did-stop-loading', onLoadStop);
- wv.removeEventListener('new-window', onNewWindow);
- };
- }, [browserId, dispatch]);
+ // ---- Webview ref management ----
+ const webviewMap = useRef