mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[Haik]: ckpt, this is a big one babayyy (dashboards now show screenshot previews of their contents like Apps) (Views are now called Apps) (Tools are called Actions) (Auto layout now preserves sizing and just works much better) (Browsers and Apps have larger spawn in size) (Tutorial info for how to get an anthropic key to help users - in the settings) (Added Descriptions to all 3 Main Pages in Navbar) (Also reorganized navbar and made it resizable) (Browsers now support multiple tabs) (Move the Commands page to settings - also beefed up settings a bit to encapsulate more) (Added inner glow on browser) (Added inputs in header of browser to act like normal google search)
This commit is contained in:
@@ -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])],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
+12
-1
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.01",
|
||||
"version": "1.0.2",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = () => {
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardSelection />} />
|
||||
<Route path="/dashboard/:id" element={<Dashboard />} />
|
||||
<Route path="/customization" element={<Customization />} />
|
||||
<Route path="/templates" element={<Templates />} />
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/tools" element={<Tools />} />
|
||||
<Route path="/actions" element={<Tools />} />
|
||||
<Route path="/modes" element={<Modes />} />
|
||||
<Route path="/commands" element={<Commands />} />
|
||||
<Route path="/views" element={<Views />} />
|
||||
<Route path="/apps" element={<Views />} />
|
||||
<Route path="/apps/:id" element={<Views />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</UpdateListener>
|
||||
|
||||
@@ -175,7 +175,7 @@ const CommandPicker: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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: <BuildOutlinedIcon sx={{ fontSize: 15 }} />,
|
||||
toolNames: svc.tools,
|
||||
@@ -274,7 +274,7 @@ const CommandPicker: React.FC<Props> = ({ 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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: <DescriptionIcon /> },
|
||||
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: <DescriptionIcon /> },
|
||||
{ label: 'Skills', path: '/skills', icon: <PsychologyIcon /> },
|
||||
{ label: 'Tools', path: '/tools', icon: <BuildIcon /> },
|
||||
{ label: 'Actions', path: '/actions', icon: <BuildIcon /> },
|
||||
{ label: 'Modes', path: '/modes', icon: <TuneIcon /> },
|
||||
{ label: 'Commands', path: '/commands', icon: <TerminalIcon /> },
|
||||
{ label: 'Views', path: '/views', icon: <ViewQuiltIcon /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
|
||||
{/* Draggable title bar */}
|
||||
@@ -188,12 +264,12 @@ const AppShell: React.FC = () => {
|
||||
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: 220,
|
||||
width: sidebarWidth,
|
||||
flexShrink: 0,
|
||||
bgcolor: c.bg.secondary,
|
||||
borderRight: `0.5px solid ${c.border.subtle}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
@@ -313,46 +389,210 @@ const AppShell: React.FC = () => {
|
||||
{/* Divider */}
|
||||
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
|
||||
|
||||
{/* Nav items */}
|
||||
<Box sx={{ px: 1 }}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<ListItemButton
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.25,
|
||||
mb: 0.25,
|
||||
bgcolor: isActive ? `${c.accent.primary}12` : 'transparent',
|
||||
'&:hover': { bgcolor: isActive ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
{/* Customization section */}
|
||||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: isCustomizationRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
|
||||
<ExtensionIcon sx={{ fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Customization"
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isCustomizationRoute ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: isCustomizationRoute ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
fontSize: 16,
|
||||
transition: 'transform 0.2s',
|
||||
transform: customizationExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
<Collapse in={customizationExpanded} timeout={200}>
|
||||
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
|
||||
{CUSTOMIZATION_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{ color: isActive ? c.accent.primary : c.text.tertiary, minWidth: 32 }}
|
||||
>
|
||||
{React.cloneElement(item.icon, { sx: { fontSize: 20 } })}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isActive ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
|
||||
|
||||
{/* Apps section */}
|
||||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||||
<ListItemButton
|
||||
onClick={handleAppsClick}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.25,
|
||||
bgcolor: isAppsRoute ? `${c.accent.primary}12` : 'transparent',
|
||||
'&:hover': { bgcolor: isAppsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: isAppsRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
|
||||
<ViewQuiltIcon sx={{ fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Apps"
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isAppsRoute ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: isAppsRoute ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="New app" placement="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCreateApp}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.25,
|
||||
mr: 0.25,
|
||||
borderRadius: 1,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{appsList.length > 0 && (
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
fontSize: 16,
|
||||
transition: 'transform 0.2s',
|
||||
transform: appsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
|
||||
<Collapse in={appsExpanded && appsList.length > 0} timeout={200}>
|
||||
<Box
|
||||
sx={{
|
||||
ml: 2,
|
||||
mt: 0.25,
|
||||
mb: 0.5,
|
||||
borderLeft: `1px solid ${c.border.medium}`,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 3 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
{appsList.map((app) => {
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<Box
|
||||
key={app.id}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{app.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Settings */}
|
||||
@@ -403,6 +643,35 @@ const AppShell: React.FC = () => {
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
onMouseDown={handleResizeStart}
|
||||
onDoubleClick={handleResizeDoubleClick}
|
||||
sx={{
|
||||
width: 6,
|
||||
flexShrink: 0,
|
||||
cursor: 'col-resize',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 2,
|
||||
bgcolor: 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
},
|
||||
'&:hover::after': {
|
||||
bgcolor: c.border.strong,
|
||||
},
|
||||
'&:active::after': {
|
||||
bgcolor: `${c.accent.primary}40`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page }}>
|
||||
|
||||
@@ -68,7 +68,7 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
|
||||
const isLabelFloating = focused || hasContent;
|
||||
|
||||
// Sync external value → editor on mount / when value changes externally
|
||||
const lastEmittedRef = useRef(value);
|
||||
const lastEmittedRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
@@ -327,7 +327,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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;
|
||||
|
||||
@@ -206,7 +206,7 @@ function buildContextGroups(
|
||||
key: 'tools',
|
||||
icon: <BuildOutlinedIcon sx={{ fontSize: 13 }} />,
|
||||
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: <BuildOutlinedIcon sx={{ fontSize: 12 }} />,
|
||||
|
||||
@@ -38,7 +38,7 @@ const ViewBubble: React.FC<Props> = ({ 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();
|
||||
|
||||
@@ -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<{
|
||||
</Box>
|
||||
);
|
||||
|
||||
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: <BuildOutlinedIcon sx={{ fontSize: 18 }} />,
|
||||
source: tool.name,
|
||||
});
|
||||
@@ -267,27 +266,9 @@ const Commands: React.FC = () => {
|
||||
const actionShortcuts = SHORTCUTS.filter((s) => s.category === 'action');
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>
|
||||
Commands
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>
|
||||
Manage slash commands, context references, and keyboard shortcuts in one place.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 900 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Slash Commands */}
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 3,
|
||||
boxShadow: c.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<SectionHeader
|
||||
icon={<TerminalIcon sx={{ fontSize: 22 }} />}
|
||||
title="Slash Commands"
|
||||
@@ -385,22 +366,16 @@ const Commands: React.FC = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
|
||||
|
||||
{/* @ Commands */}
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 3,
|
||||
boxShadow: c.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<SectionHeader
|
||||
icon={<AlternateEmailIcon sx={{ fontSize: 22 }} />}
|
||||
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 = () => {
|
||||
>
|
||||
<AlternateEmailIcon sx={{ fontSize: 36, opacity: 0.3 }} />
|
||||
<Typography sx={{ fontSize: '0.85rem' }}>
|
||||
No @ commands yet. Install MCP tools to see them here.
|
||||
No @ commands yet. Install MCP actions to see them here.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -480,18 +455,12 @@ const Commands: React.FC = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ my: 2, borderTop: `1px solid ${c.border.subtle}` }} />
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 3,
|
||||
boxShadow: c.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<SectionHeader
|
||||
icon={<KeyboardIcon sx={{ fontSize: 22 }} />}
|
||||
title="Keyboard Shortcuts"
|
||||
@@ -579,10 +548,9 @@ const Commands: React.FC = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Commands;
|
||||
export default CommandsContent;
|
||||
|
||||
@@ -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: <DescriptionIcon />,
|
||||
description:
|
||||
'Create and manage reusable prompt templates with structured input fields that your agents can fill in.',
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
path: '/skills',
|
||||
icon: <PsychologyIcon />,
|
||||
description:
|
||||
'Install or author reusable skill packages that teach your agents new capabilities and workflows.',
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
path: '/actions',
|
||||
icon: <BuildIcon />,
|
||||
description:
|
||||
'Define and manage the actions your agents can take.',
|
||||
},
|
||||
{
|
||||
label: 'Modes',
|
||||
path: '/modes',
|
||||
icon: <TuneIcon />,
|
||||
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 (
|
||||
<Box sx={{ height: '100%', overflow: 'auto', p: 4 }}>
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: c.text.primary }}>
|
||||
Customization
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem', mt: 0.5 }}>
|
||||
Tailor how your agents behave, what they can do, and how they interact.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 2.5,
|
||||
}}
|
||||
>
|
||||
{PANELS.map((panel) => (
|
||||
<Card
|
||||
key={panel.path}
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 2.5,
|
||||
boxShadow: c.shadow.sm,
|
||||
'&:hover': {
|
||||
borderColor: c.accent.primary,
|
||||
boxShadow: `0 0 0 1px ${c.accent.primary}22`,
|
||||
},
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={() => navigate(panel.path)}
|
||||
sx={{ p: 3, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1.5 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${c.accent.primary}12`,
|
||||
color: c.accent.primary,
|
||||
}}
|
||||
>
|
||||
{React.cloneElement(panel.icon, { sx: { fontSize: 24 } })}
|
||||
</Box>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1.05rem' }}>
|
||||
{panel.label}
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.55 }}>
|
||||
{panel.description}
|
||||
</Typography>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Customization;
|
||||
@@ -791,4 +791,4 @@ const AgentCard: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCard;
|
||||
export default React.memo(AgentCard);
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<WebviewElement | null>(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<Record<string, TabLocalState>>({});
|
||||
const updateTabLocal = useCallback((tabId: string, update: Partial<TabLocalState>) => {
|
||||
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<Map<string, WebviewElement>>(new Map());
|
||||
const initializedTabs = useRef(new Set<string>());
|
||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const wv = webviewRef.current;
|
||||
if (!wv) return;
|
||||
registerWebview(browserId, wv);
|
||||
return () => { unregisterWebview(browserId); };
|
||||
}, [browserId]);
|
||||
setRegistryActiveTab(browserId, activeTabId);
|
||||
}, [browserId, activeTabId]);
|
||||
|
||||
const tabIdKey = tabs.map((t) => t.id).join(',');
|
||||
useEffect(() => {
|
||||
if (!isElectron) return;
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
for (const tab of tabs) {
|
||||
const wv = webviewMap.current.get(tab.id);
|
||||
if (!wv) continue;
|
||||
const tabId = tab.id;
|
||||
|
||||
registerWebview(browserId, tabId, wv);
|
||||
|
||||
if (!initializedTabs.current.has(tabId)) {
|
||||
initializedTabs.current.add(tabId);
|
||||
const targetUrl = tab.url;
|
||||
const doLoad = () => {
|
||||
wv.loadURL(targetUrl).catch(() => {});
|
||||
};
|
||||
wv.addEventListener('dom-ready', doLoad, { once: true });
|
||||
cleanups.push(() => wv.removeEventListener('dom-ready', doLoad));
|
||||
}
|
||||
|
||||
const onNavigate = () => {
|
||||
const newUrl = wv.getURL();
|
||||
dispatch(updateBrowserTabUrl({ browserId, tabId, url: newUrl }));
|
||||
updateTabLocal(tabId, {
|
||||
canGoBack: wv.canGoBack(),
|
||||
canGoForward: wv.canGoForward(),
|
||||
});
|
||||
};
|
||||
|
||||
const onTitleUpdate = () => {
|
||||
dispatch(updateBrowserTabTitle({ browserId, tabId, title: wv.getTitle() }));
|
||||
};
|
||||
|
||||
const onLoadStart = () => updateTabLocal(tabId, { loading: true });
|
||||
const onLoadStop = () => {
|
||||
updateTabLocal(tabId, { loading: false });
|
||||
onNavigate();
|
||||
onTitleUpdate();
|
||||
};
|
||||
|
||||
const onNewWindow = (e: any) => {
|
||||
if (e.url) dispatch(addBrowserTab({ browserId, url: e.url, makeActive: true }));
|
||||
};
|
||||
|
||||
const onFaviconUpdate = (e: any) => {
|
||||
const favicons = e.favicons || (e.detail && e.detail.favicons);
|
||||
if (favicons?.[0]) {
|
||||
dispatch(updateBrowserTabFavicon({ browserId, tabId, favicon: favicons[0] }));
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
wv.addEventListener('page-favicon-updated', onFaviconUpdate);
|
||||
|
||||
cleanups.push(() => {
|
||||
unregisterWebview(browserId, tabId);
|
||||
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);
|
||||
wv.removeEventListener('page-favicon-updated', onFaviconUpdate);
|
||||
});
|
||||
}
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tabIdKey, browserId, dispatch, updateTabLocal]);
|
||||
|
||||
// ---- Navigation (active tab) ----
|
||||
const navigate = useCallback((targetUrl: string) => {
|
||||
const finalUrl = resolveInput(targetUrl);
|
||||
setUrlBarValue(finalUrl);
|
||||
if (isElectron && webviewRef.current) {
|
||||
webviewRef.current.loadURL(finalUrl).catch((err: Error) => {
|
||||
const wv = webviewMap.current.get(activeTabId);
|
||||
if (isElectron && wv) {
|
||||
wv.loadURL(finalUrl).catch((err: Error) => {
|
||||
if (!err.message?.includes('ERR_ABORTED')) console.error('Navigation failed:', err);
|
||||
});
|
||||
}
|
||||
setCurrentUrl(finalUrl);
|
||||
dispatch(updateBrowserCardUrl({ browserId, url: finalUrl }));
|
||||
}, [browserId, dispatch]);
|
||||
dispatch(updateBrowserTabUrl({ browserId, tabId: activeTabId, url: finalUrl }));
|
||||
}, [browserId, activeTabId, dispatch]);
|
||||
|
||||
const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -166,25 +236,120 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
const handleBack = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.goBack();
|
||||
}, []);
|
||||
webviewMap.current.get(activeTabId)?.goBack();
|
||||
}, [activeTabId]);
|
||||
|
||||
const handleForward = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.goForward();
|
||||
}, []);
|
||||
webviewMap.current.get(activeTabId)?.goForward();
|
||||
}, [activeTabId]);
|
||||
|
||||
const handleRefresh = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
webviewRef.current?.reload();
|
||||
}, []);
|
||||
webviewMap.current.get(activeTabId)?.reload();
|
||||
}, [activeTabId]);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(removeBrowserCard(browserId));
|
||||
}, [dispatch, browserId]);
|
||||
|
||||
// ---- Drag via header ----
|
||||
// ---- Tab management ----
|
||||
const handleAddTab = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(addBrowserTab({ browserId, url: browserHomepage }));
|
||||
}, [dispatch, browserId, browserHomepage]);
|
||||
|
||||
const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(removeBrowserTab({ browserId, tabId }));
|
||||
}, [dispatch, browserId]);
|
||||
|
||||
const handleSwitchTab = useCallback((tabId: string) => {
|
||||
dispatch(setActiveBrowserTab({ browserId, tabId }));
|
||||
}, [dispatch, browserId]);
|
||||
|
||||
// ---- Tab drag reorder ----
|
||||
const tabDragRef = useRef<{
|
||||
tabId: string;
|
||||
startX: number;
|
||||
isDragging: boolean;
|
||||
} | null>(null);
|
||||
const swapCooldown = useRef(false);
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
const [dragTabOffset, setDragTabOffset] = useState(0);
|
||||
|
||||
const handleTabPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
const tabId = (e.currentTarget as HTMLElement).getAttribute('data-tab-id');
|
||||
if (!tabId) return;
|
||||
tabDragRef.current = { tabId, startX: e.clientX, isDragging: false };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleTabPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
const drag = tabDragRef.current;
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - drag.startX;
|
||||
if (!drag.isDragging && Math.abs(dx) < 5) return;
|
||||
drag.isDragging = true;
|
||||
setDragTabId(drag.tabId);
|
||||
setDragTabOffset(dx);
|
||||
|
||||
if (swapCooldown.current) return;
|
||||
const bar = tabBarRef.current;
|
||||
if (!bar) return;
|
||||
|
||||
const draggedEl = bar.querySelector(`[data-tab-id="${drag.tabId}"]`) as HTMLElement | null;
|
||||
if (!draggedEl) return;
|
||||
const rect = draggedEl.getBoundingClientRect();
|
||||
const center = rect.left + rect.width / 2 + dx;
|
||||
const currentIdx = tabs.findIndex((t) => t.id === drag.tabId);
|
||||
|
||||
if (currentIdx < tabs.length - 1) {
|
||||
const nextId = tabs[currentIdx + 1].id;
|
||||
const nextEl = bar.querySelector(`[data-tab-id="${nextId}"]`) as HTMLElement | null;
|
||||
if (nextEl) {
|
||||
const nr = nextEl.getBoundingClientRect();
|
||||
if (center > nr.left + nr.width / 2) {
|
||||
dispatch(reorderBrowserTab({ browserId, tabId: drag.tabId, toIndex: currentIdx + 1 }));
|
||||
drag.startX = e.clientX;
|
||||
setDragTabOffset(0);
|
||||
swapCooldown.current = true;
|
||||
requestAnimationFrame(() => { swapCooldown.current = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentIdx > 0) {
|
||||
const prevId = tabs[currentIdx - 1].id;
|
||||
const prevEl = bar.querySelector(`[data-tab-id="${prevId}"]`) as HTMLElement | null;
|
||||
if (prevEl) {
|
||||
const pr = prevEl.getBoundingClientRect();
|
||||
if (center < pr.left + pr.width / 2) {
|
||||
dispatch(reorderBrowserTab({ browserId, tabId: drag.tabId, toIndex: currentIdx - 1 }));
|
||||
drag.startX = e.clientX;
|
||||
setDragTabOffset(0);
|
||||
swapCooldown.current = true;
|
||||
requestAnimationFrame(() => { swapCooldown.current = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [tabs, browserId, dispatch]);
|
||||
|
||||
const handleTabPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
const drag = tabDragRef.current;
|
||||
if (!drag) return;
|
||||
if (!drag.isDragging) {
|
||||
handleSwitchTab(drag.tabId);
|
||||
}
|
||||
tabDragRef.current = null;
|
||||
setDragTabId(null);
|
||||
setDragTabOffset(0);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [handleSwitchTab]);
|
||||
|
||||
// ---- Card drag via tab bar background ----
|
||||
const DRAG_THRESHOLD = 3;
|
||||
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -299,6 +464,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, browserId]);
|
||||
|
||||
// ---- Display calculations ----
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
|
||||
@@ -307,12 +473,13 @@ const BrowserCard: React.FC<Props> = ({
|
||||
const displayH = localResize?.h ?? cardHeight;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
const isSecure = currentUrl.startsWith('https://');
|
||||
const isSearch = isGoogleSearch(currentUrl);
|
||||
const isSecure = activeUrl.startsWith('https://');
|
||||
const isSearch = isGoogleSearch(activeUrl);
|
||||
|
||||
const accentColor = c.accent.primary;
|
||||
const accentHover = c.accent.hover;
|
||||
|
||||
// ---- Glow state ----
|
||||
const glowingBrowserCards = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards);
|
||||
const isGlowingFromRedux = !!glowingBrowserCards[browserId];
|
||||
|
||||
@@ -350,7 +517,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
<Box
|
||||
data-select-type="browser-card"
|
||||
data-select-id={browserId}
|
||||
data-select-meta={JSON.stringify({ name: pageTitle || 'Browser', url: currentUrl })}
|
||||
data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(browserId, 'browser', e.shiftKey);
|
||||
@@ -434,87 +601,232 @@ const BrowserCard: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header / drag handle */}
|
||||
{/* ====== Tab bar / drag handle ====== */}
|
||||
<Box
|
||||
ref={tabBarRef}
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
alignItems: 'stretch',
|
||||
bgcolor: agentActive ? `${accentColor}0a` : c.bg.secondary,
|
||||
borderBottom: `1px solid ${agentActive ? `${accentColor}30` : c.border.subtle}`,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
flexShrink: 0,
|
||||
minHeight: 36,
|
||||
minHeight: 34,
|
||||
userSelect: 'none',
|
||||
transition: 'background 0.3s ease',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Typography
|
||||
{/* Scrollable tab strip */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
{pageTitle || 'Browser'}
|
||||
</Typography>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isBeingDragged = tab.id === dragTabId;
|
||||
const tls = tabLocalStates[tab.id];
|
||||
|
||||
{/* Agent activity badge */}
|
||||
{agentActive && (
|
||||
return (
|
||||
<Box
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
onPointerDown={handleTabPointerDown}
|
||||
onPointerMove={handleTabPointerMove}
|
||||
onPointerUp={handleTabPointerUp}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 180,
|
||||
flex: '0 1 180px',
|
||||
position: 'relative',
|
||||
borderRight: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: isActive ? c.bg.surface : 'transparent',
|
||||
cursor: isBeingDragged ? 'grabbing' : 'pointer',
|
||||
transform: isBeingDragged ? `translateX(${dragTabOffset}px)` : 'none',
|
||||
transition: isBeingDragged ? 'none' : 'background 0.15s ease, transform 0.2s ease',
|
||||
zIndex: isBeingDragged ? 10 : 1,
|
||||
'&:hover': { bgcolor: isActive ? c.bg.surface : c.bg.hover },
|
||||
'&:hover .tab-close': { opacity: 1 },
|
||||
...(isActive && {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
bgcolor: accentColor,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Favicon / loading spinner */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, width: 14, height: 14, justifyContent: 'center' }}>
|
||||
{tls?.loading ? (
|
||||
<CircularProgress size={10} thickness={5} sx={{ color: accentColor }} />
|
||||
) : tab.favicon ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={tab.favicon}
|
||||
sx={{ width: 14, height: 14, borderRadius: '2px' }}
|
||||
onError={(e: any) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<LanguageIcon sx={{ fontSize: 13, color: isActive ? accentColor : c.text.ghost }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? c.text.primary : c.text.muted,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{tab.title || 'New Tab'}
|
||||
</Typography>
|
||||
|
||||
{/* Close tab */}
|
||||
<Box
|
||||
className="tab-close"
|
||||
onClick={(e: React.MouseEvent) => handleCloseTab(tab.id, e)}
|
||||
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '4px',
|
||||
flexShrink: 0,
|
||||
opacity: isActive ? 0.6 : 0,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
'&:hover': { bgcolor: `${c.text.muted}25`, opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 10, color: c.text.muted }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add tab (+) button */}
|
||||
<Box
|
||||
onClick={handleAddTab}
|
||||
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: '6px',
|
||||
bgcolor: `${accentColor}18`,
|
||||
border: `1px solid ${accentColor}30`,
|
||||
animation: 'badge-fade-in 0.25s ease-out',
|
||||
'@keyframes badge-fade-in': {
|
||||
'0%': { opacity: 0, transform: 'scale(0.85)' },
|
||||
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
mx: 0.25,
|
||||
my: 0.5,
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': { bgcolor: `${c.text.muted}15` },
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 15, color: c.text.muted }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right side controls */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, flexShrink: 0 }}>
|
||||
{/* Agent activity badge */}
|
||||
{agentActive && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: accentColor,
|
||||
animation: 'badge-dot-pulse 1.4s ease-in-out infinite',
|
||||
'@keyframes badge-dot-pulse': {
|
||||
'0%, 100%': { opacity: 0.5, transform: 'scale(0.8)' },
|
||||
'50%': { opacity: 1, transform: 'scale(1.3)' },
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: '6px',
|
||||
bgcolor: `${accentColor}18`,
|
||||
border: `1px solid ${accentColor}30`,
|
||||
animation: 'badge-fade-in 0.25s ease-out',
|
||||
'@keyframes badge-fade-in': {
|
||||
'0%': { opacity: 0, transform: 'scale(0.85)' },
|
||||
'100%': { opacity: 1, transform: 'scale(1)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: accentColor, lineHeight: 1 }}>
|
||||
AI
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: accentColor,
|
||||
animation: 'badge-dot-pulse 1.4s ease-in-out infinite',
|
||||
'@keyframes badge-dot-pulse': {
|
||||
'0%, 100%': { opacity: 0.5, transform: 'scale(0.8)' },
|
||||
'50%': { opacity: 1, transform: 'scale(1.3)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: accentColor, lineHeight: 1 }}>
|
||||
AI
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Tooltip title="Close browser" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ====== Navigation bar ====== */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
bgcolor: c.bg.page,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Back" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!canGoBack}
|
||||
disabled={!activeLocal.canGoBack}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 15 }} />
|
||||
@@ -528,7 +840,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
size="small"
|
||||
onClick={handleForward}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!canGoForward}
|
||||
disabled={!activeLocal.canGoForward}
|
||||
sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<ArrowForwardIcon sx={{ fontSize: 15 }} />
|
||||
@@ -547,57 +859,48 @@ const BrowserCard: React.FC<Props> = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close browser" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* URL bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
bgcolor: c.bg.page,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isSearch ? (
|
||||
<SearchIcon sx={{ fontSize: 14, color: c.text.muted, flexShrink: 0 }} />
|
||||
) : isSecure ? (
|
||||
<LockIcon sx={{ fontSize: 13, color: c.status.success, flexShrink: 0 }} />
|
||||
) : null}
|
||||
<InputBase
|
||||
value={urlBarValue}
|
||||
onChange={(e) => setUrlBarValue(e.target.value)}
|
||||
onKeyDown={handleUrlKeyDown}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
placeholder="Search Google or enter URL..."
|
||||
{/* URL bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
fontSize: '0.76rem',
|
||||
fontFamily: c.font.mono,
|
||||
color: c.text.secondary,
|
||||
py: 0,
|
||||
'& input': { py: '3px' },
|
||||
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
|
||||
gap: 0.5,
|
||||
ml: 0.5,
|
||||
px: 1,
|
||||
py: 0.2,
|
||||
bgcolor: c.bg.secondary,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{isSearch ? (
|
||||
<SearchIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
|
||||
) : isSecure ? (
|
||||
<LockIcon sx={{ fontSize: 12, color: c.status.success, flexShrink: 0 }} />
|
||||
) : null}
|
||||
<InputBase
|
||||
value={urlBarValue}
|
||||
onChange={(e) => setUrlBarValue(e.target.value)}
|
||||
onKeyDown={handleUrlKeyDown}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => (e.target as HTMLInputElement).select()}
|
||||
placeholder="Search Google or enter URL..."
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.74rem',
|
||||
fontFamily: c.font.mono,
|
||||
color: c.text.secondary,
|
||||
py: 0,
|
||||
'& input': { py: '2px' },
|
||||
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Loading indicator — accent-colored when agent is navigating */}
|
||||
{(loading || (agentActive && agentAction === 'navigate')) && (
|
||||
{/* Loading indicator */}
|
||||
{(activeLocal.loading || (agentActive && agentAction === 'navigate')) && (
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 2,
|
||||
@@ -610,18 +913,35 @@ const BrowserCard: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Browser body */}
|
||||
{/* ====== Browser body — multiple webviews stacked ====== */}
|
||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
{isElectron ? (
|
||||
<webview
|
||||
ref={webviewRef as any}
|
||||
src={currentUrl}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
/>
|
||||
tabs.map((tab) => (
|
||||
<webview
|
||||
key={tab.id}
|
||||
ref={(el: any) => {
|
||||
if (el) webviewMap.current.set(tab.id, el as unknown as WebviewElement);
|
||||
else webviewMap.current.delete(tab.id);
|
||||
}}
|
||||
data-tab-id={tab.id}
|
||||
src="about:blank"
|
||||
allowpopups="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
visibility: tab.id === activeTabId ? 'visible' : 'hidden',
|
||||
zIndex: tab.id === activeTabId ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<iframe
|
||||
src={currentUrl}
|
||||
src={activeUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="Browser"
|
||||
@@ -850,4 +1170,4 @@ const BrowserCard: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserCard;
|
||||
export default React.memo(BrowserCard);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { store } from '@/shared/state/store';
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchHistory,
|
||||
collapseAllSessions,
|
||||
collapseSession,
|
||||
launchAndSendFirstMessage,
|
||||
generateTitle,
|
||||
@@ -26,9 +25,10 @@ import {
|
||||
moveCards,
|
||||
resetLayout,
|
||||
setGlowingBrowserCards,
|
||||
EXPANDED_CARD_MIN_H,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
|
||||
import { generateDashboardName, updateDashboardThumbnail } from '@/shared/state/dashboardsSlice';
|
||||
import { dashboardWs } from '@/shared/ws/WebSocketManager';
|
||||
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
|
||||
import AgentCard from './AgentCard';
|
||||
@@ -36,6 +36,7 @@ import DashboardViewCard from './DashboardViewCard';
|
||||
import BrowserCard from './BrowserCard';
|
||||
import CanvasControls from './CanvasControls';
|
||||
import DashboardToolbar from './DashboardToolbar';
|
||||
import { captureDashboardThumbnail } from './captureDashboardThumbnail';
|
||||
import { useCanvasControls } from './useCanvasControls';
|
||||
import { useDashboardSelection } from './useDashboardSelection';
|
||||
import type { CardType } from './useDashboardSelection';
|
||||
@@ -94,6 +95,8 @@ const DashboardInner: React.FC = () => {
|
||||
const spawnOriginsRef = useRef<Record<string, { x: number; y: number }>>({});
|
||||
const hasFittedRef = useRef(false);
|
||||
const restoredExpandedRef = useRef(false);
|
||||
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
|
||||
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
|
||||
|
||||
// ---- Multi-drag coordination ----
|
||||
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
|
||||
@@ -148,7 +151,7 @@ const DashboardInner: React.FC = () => {
|
||||
} else {
|
||||
canvas.handlers.onMouseDown(e);
|
||||
}
|
||||
}, [canvas, selection]);
|
||||
}, [canvas.handlers, canvas.spaceHeld, selection]);
|
||||
|
||||
const handleViewportMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
canvas.handlers.onMouseMove(e);
|
||||
@@ -174,6 +177,51 @@ const DashboardInner: React.FC = () => {
|
||||
return () => { cleanupBrowserHandler(); dashboardWs.disconnect(); };
|
||||
}, [dispatch, dashboardId]);
|
||||
|
||||
// Capture a thumbnail screenshot of the dashboard.
|
||||
// Uses Electron's native capturePage for pixel-perfect results.
|
||||
// Captures current viewport as-is (no DOM mutation) to avoid visual flashes.
|
||||
// Re-captures when layout is saved (piggybacking on the save debounce).
|
||||
const pendingThumbnailRef = useRef<string | null>(null);
|
||||
const captureTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const captureNow = useCallback(() => {
|
||||
const viewportEl = canvas.viewportRef.current;
|
||||
const contentEl = canvas.contentRef.current;
|
||||
if (!viewportEl || !contentEl) return;
|
||||
const layoutState = store.getState().dashboardLayout;
|
||||
const allCards = {
|
||||
cards: layoutState.cards,
|
||||
viewCards: layoutState.viewCards,
|
||||
browserCards: layoutState.browserCards,
|
||||
};
|
||||
const hasCards = Object.keys(allCards.cards).length > 0
|
||||
|| Object.keys(allCards.viewCards).length > 0
|
||||
|| Object.keys(allCards.browserCards).length > 0;
|
||||
if (!hasCards) return;
|
||||
captureDashboardThumbnail(viewportEl, contentEl, allCards)
|
||||
.then((thumbnail) => { if (thumbnail) pendingThumbnailRef.current = thumbnail; })
|
||||
.catch(() => {});
|
||||
}, [canvas.viewportRef, canvas.contentRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId || !layoutInitialized) return;
|
||||
if (captureTimerRef.current) clearTimeout(captureTimerRef.current);
|
||||
captureTimerRef.current = setTimeout(captureNow, 2000);
|
||||
return () => { if (captureTimerRef.current) clearTimeout(captureTimerRef.current); };
|
||||
}, [dashboardId, layoutInitialized, captureNow]);
|
||||
|
||||
// On exit, save the captured thumbnail to the backend
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
const exitingId = dashboardId;
|
||||
return () => {
|
||||
const thumbnail = pendingThumbnailRef.current;
|
||||
if (thumbnail) {
|
||||
store.dispatch(updateDashboardThumbnail({ id: exitingId, thumbnail }));
|
||||
pendingThumbnailRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || hasFittedRef.current) return;
|
||||
hasFittedRef.current = true;
|
||||
@@ -184,9 +232,7 @@ const DashboardInner: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || restoredExpandedRef.current) return;
|
||||
restoredExpandedRef.current = true;
|
||||
if (persistedExpandedSessionIds.length > 0) {
|
||||
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
|
||||
}
|
||||
dispatch(setExpandedSessionIds(persistedExpandedSessionIds));
|
||||
}, [layoutInitialized, persistedExpandedSessionIds, dispatch]);
|
||||
|
||||
const prevSessionIdsRef = useRef<string>('');
|
||||
@@ -199,22 +245,42 @@ const DashboardInner: React.FC = () => {
|
||||
const liveIds = dashboardSessionIds.sort().join(',');
|
||||
if (liveIds === prevSessionIdsRef.current) return;
|
||||
prevSessionIdsRef.current = liveIds;
|
||||
dispatch(reconcileSessions(dashboardSessionIds));
|
||||
}, [sessions, layoutInitialized, dispatch, dashboardId]);
|
||||
dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds }));
|
||||
}, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]);
|
||||
|
||||
const cardsJson = JSON.stringify(cards);
|
||||
const viewCardsJson = JSON.stringify(viewCards);
|
||||
const browserCardsJson = JSON.stringify(browserCards);
|
||||
const expandedJson = JSON.stringify(expandedSessionIds);
|
||||
const skipInitialSave = useRef(true);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = useRef<Parameters<typeof saveLayout>[0] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || !dashboardId) return;
|
||||
if (skipInitialSave.current) {
|
||||
skipInitialSave.current = false;
|
||||
return;
|
||||
}
|
||||
dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, expandedSessionIds }));
|
||||
}, [cardsJson, viewCardsJson, browserCardsJson, expandedJson, layoutInitialized, dashboardId]);
|
||||
const payload = { dashboardId, cards, viewCards, browserCards, expandedSessionIds };
|
||||
pendingSaveRef.current = payload;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
dispatch(saveLayout(payload));
|
||||
pendingSaveRef.current = null;
|
||||
saveTimerRef.current = null;
|
||||
captureNow();
|
||||
}, 500);
|
||||
}, [cards, viewCards, browserCards, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
if (pendingSaveRef.current) {
|
||||
dispatch(saveLayout(pendingSaveRef.current));
|
||||
pendingSaveRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const parts = newAgentShortcut.toLowerCase().split('+');
|
||||
@@ -267,9 +333,10 @@ const DashboardInner: React.FC = () => {
|
||||
const vr = vpEl.getBoundingClientRect();
|
||||
const toolbarCenterX = tr.left + tr.width / 2;
|
||||
const toolbarTopY = tr.top;
|
||||
const { panX, panY, zoom } = canvasStateRef.current;
|
||||
spawnOriginsRef.current[draftId] = {
|
||||
x: (toolbarCenterX - vr.left - canvas.panX) / canvas.zoom,
|
||||
y: (toolbarTopY - vr.top - canvas.panY) / canvas.zoom,
|
||||
x: (toolbarCenterX - vr.left - panX) / zoom,
|
||||
y: (toolbarTopY - vr.top - panY) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,16 +385,16 @@ const DashboardInner: React.FC = () => {
|
||||
}
|
||||
});
|
||||
},
|
||||
[canvas.zoom, canvas.panX, canvas.panY, canvas.viewportRef, dispatch, dashboardId],
|
||||
[canvas.viewportRef, dispatch, dashboardId],
|
||||
);
|
||||
|
||||
const handleAddView = useCallback((outputId: string) => {
|
||||
dispatch(addViewCard({ outputId }));
|
||||
}, [dispatch]);
|
||||
dispatch(addViewCard({ outputId, expandedSessionIds }));
|
||||
}, [dispatch, expandedSessionIds]);
|
||||
|
||||
const handleAddBrowser = useCallback(() => {
|
||||
dispatch(addBrowserCard({ url: browserHomepage }));
|
||||
}, [dispatch, browserHomepage]);
|
||||
dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds }));
|
||||
}, [dispatch, browserHomepage, expandedSessionIds]);
|
||||
|
||||
const handleHistoryResume = useCallback((sessionId: string) => {
|
||||
dispatch(resumeSession({ sessionId })).then((action) => {
|
||||
@@ -338,12 +405,16 @@ const DashboardInner: React.FC = () => {
|
||||
}, [dispatch]);
|
||||
|
||||
const handleTidy = useCallback(() => {
|
||||
dispatch(collapseAllSessions());
|
||||
dispatch(tidyLayout());
|
||||
const currentExpanded = store.getState().agents.expandedSessionIds;
|
||||
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
|
||||
|
||||
const expandedSet = new Set(currentExpanded);
|
||||
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
|
||||
const allRects = [
|
||||
...Object.values(tidied).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidied).map((c) => ({
|
||||
x: c.x, y: c.y, width: c.width,
|
||||
height: expandedSet.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height,
|
||||
})),
|
||||
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
];
|
||||
@@ -516,7 +587,8 @@ const DashboardInner: React.FC = () => {
|
||||
<BrowserCard
|
||||
key={`browser-${bc.browser_id}`}
|
||||
browserId={bc.browser_id}
|
||||
url={bc.url}
|
||||
tabs={bc.tabs}
|
||||
activeTabId={bc.activeTabId}
|
||||
cardX={bc.x}
|
||||
cardY={bc.y}
|
||||
cardWidth={bc.width}
|
||||
|
||||
@@ -423,7 +423,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
inputRef={searchInputRef}
|
||||
value={viewSearch}
|
||||
onChange={(e) => setViewSearch(e.target.value)}
|
||||
placeholder="Search views..."
|
||||
placeholder="Search apps..."
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.85rem',
|
||||
@@ -451,7 +451,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
{filteredOutputs.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted }}>
|
||||
{outputList.length === 0 ? 'No views created yet' : 'No matching views'}
|
||||
{outputList.length === 0 ? 'No apps created yet' : 'No matching apps'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -384,4 +384,4 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardViewCard;
|
||||
export default React.memo(DashboardViewCard);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
interface AllCards {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a screenshot of the dashboard viewport using Electron's native
|
||||
* capturePage API. Captures the viewport as-is (current pan/zoom) to avoid
|
||||
* mutating the DOM transform and causing visible flashes.
|
||||
*/
|
||||
export async function captureDashboardThumbnail(
|
||||
viewportEl: HTMLDivElement,
|
||||
_contentEl: HTMLDivElement,
|
||||
_allCards: AllCards,
|
||||
): Promise<string | null> {
|
||||
const openswarm = (window as any).openswarm;
|
||||
if (!openswarm?.capturePage) return null;
|
||||
|
||||
const vRect = viewportEl.getBoundingClientRect();
|
||||
if (vRect.width === 0 || vRect.height === 0) return null;
|
||||
|
||||
try {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const captureRect = {
|
||||
x: Math.round(vRect.x * dpr),
|
||||
y: Math.round(vRect.y * dpr),
|
||||
width: Math.round(vRect.width * dpr),
|
||||
height: Math.round(vRect.height * dpr),
|
||||
};
|
||||
|
||||
const dataUrl: string = await openswarm.capturePage(captureRect);
|
||||
return dataUrl || null;
|
||||
} catch (err) {
|
||||
console.warn('Dashboard thumbnail capture failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 3.0;
|
||||
@@ -32,6 +32,8 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
const [cmdHeld, setCmdHeld] = useState(false);
|
||||
|
||||
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const spaceRef = useRef(false);
|
||||
const cmdRef = useRef(false);
|
||||
const sensitivityRef = useRef(zoomSensitivity);
|
||||
@@ -171,10 +173,10 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
panStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: state.panX,
|
||||
panY: state.panY,
|
||||
panX: stateRef.current.panX,
|
||||
panY: stateRef.current.panY,
|
||||
};
|
||||
}, [state.panX, state.panY]);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const start = panStartRef.current;
|
||||
@@ -307,6 +309,16 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
setState({ panX: newPanX, panY: newPanY, zoom: newZoom });
|
||||
}, []);
|
||||
|
||||
const handlers = useMemo(() => ({
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseMove: handleMouseMove,
|
||||
onMouseUp: handleMouseUp,
|
||||
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const actions = useMemo(() => ({
|
||||
zoomIn, zoomOut, resetZoom, fitToView, fitToCards,
|
||||
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isPanning,
|
||||
@@ -314,12 +326,8 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
cmdHeld,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
handlers: {
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseMove: handleMouseMove,
|
||||
onMouseUp: handleMouseUp,
|
||||
},
|
||||
actions: { zoomIn, zoomOut, resetZoom, fitToView, fitToCards },
|
||||
handlers,
|
||||
actions,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,14 @@ const DashboardSelection: React.FC = () => {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: c.text.primary }}>
|
||||
Dashboards
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: c.text.primary }}>
|
||||
Dashboards
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem', mt: 0.5 }}>
|
||||
Monitor and manage your agents from a single workspace.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
@@ -223,9 +228,23 @@ const DashboardSelection: React.FC = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<DashboardIcon
|
||||
sx={{ fontSize: 48, color: c.accent.primary, opacity: 0.5 }}
|
||||
/>
|
||||
{d.thumbnail ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={d.thumbnail}
|
||||
alt={`${d.name} preview`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DashboardIcon
|
||||
sx={{ fontSize: 48, color: c.accent.primary, opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
className="card-actions"
|
||||
sx={{
|
||||
|
||||
@@ -184,7 +184,7 @@ const Modes: React.FC = () => {
|
||||
Modes
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>
|
||||
Configure agent interaction modes with custom system prompts, tools, and auto-switching.
|
||||
Configure agent interaction modes with custom system prompts, actions, and auto-switching.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -266,13 +266,13 @@ const Modes: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{mode.tools !== null ? (
|
||||
<Chip
|
||||
label={`${mode.tools.length} tool${mode.tools.length !== 1 ? 's' : ''}`}
|
||||
label={`${mode.tools.length} action${mode.tools.length !== 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
sx={{ bgcolor: `${mode.color}18`, color: mode.color, fontSize: '0.75rem', height: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="All tools"
|
||||
label="All actions"
|
||||
size="small"
|
||||
sx={{ bgcolor: `${mode.color}18`, color: mode.color, fontSize: '0.75rem', height: 24 }}
|
||||
/>
|
||||
@@ -369,22 +369,22 @@ const Modes: React.FC = () => {
|
||||
sx={{ color: c.text.tertiary, '&.Mui-checked': { color: c.accent.primary }, p: 0 }}
|
||||
/>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem' }}>
|
||||
Restrict tools {!form.toolsEnabled && <span style={{ color: c.text.tertiary }}>(all tools allowed)</span>}
|
||||
Restrict actions {!form.toolsEnabled && <span style={{ color: c.text.tertiary }}>(all actions allowed)</span>}
|
||||
</Typography>
|
||||
</Box>
|
||||
{form.toolsEnabled && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ color: c.text.tertiary }}>Allowed Tools</InputLabel>
|
||||
<InputLabel sx={{ color: c.text.tertiary }}>Allowed Actions</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={form.tools}
|
||||
onChange={(e) => setForm({ ...form, tools: typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value })}
|
||||
input={<OutlinedInput label="Allowed Tools" />}
|
||||
input={<OutlinedInput label="Allowed Actions" />}
|
||||
renderValue={(selected) => selected.join(', ')}
|
||||
sx={{ bgcolor: c.bg.page }}
|
||||
MenuProps={{ PaperProps: { sx: { bgcolor: c.bg.surface, color: c.text.primary } } }}
|
||||
>
|
||||
<ListSubheader sx={{ bgcolor: c.bg.page, color: c.text.tertiary, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px' }}>Built-in Tools</ListSubheader>
|
||||
<ListSubheader sx={{ bgcolor: c.bg.page, color: c.text.tertiary, fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px' }}>Built-in Actions</ListSubheader>
|
||||
{ALL_BUILTIN_TOOL_NAMES.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
<Checkbox checked={form.tools.includes(name)} size="small" sx={{ '&.Mui-checked': { color: c.accent.primary } }} />
|
||||
@@ -393,7 +393,7 @@ const Modes: React.FC = () => {
|
||||
))}
|
||||
{mcpToolNames.length > 0 && (
|
||||
<ListSubheader sx={{ bgcolor: c.bg.page, color: '#f59e0b', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: '32px', display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<ExtensionIcon sx={{ fontSize: 14 }} /> MCP Tools
|
||||
<ExtensionIcon sx={{ fontSize: 14 }} /> MCP Actions
|
||||
</ListSubheader>
|
||||
)}
|
||||
{mcpToolNames.map((name) => (
|
||||
|
||||
@@ -13,6 +13,8 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Slider from '@mui/material/Slider';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -33,12 +35,39 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettings, closeSettingsModal, AppSettings } from '@/shared/state/settingsSlice';
|
||||
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
|
||||
import { CommandsContent } from '@/app/pages/Commands/Commands';
|
||||
|
||||
const API_KEY_STEPS = [
|
||||
{
|
||||
title: 'Open the Anthropic Console',
|
||||
detail: 'Visit console.anthropic.com — create a free account if you don\'t have one yet.',
|
||||
link: 'https://console.anthropic.com',
|
||||
},
|
||||
{
|
||||
title: 'Navigate to API Keys',
|
||||
detail: 'In the dashboard, click "Settings" in the left sidebar, then select "API Keys".',
|
||||
},
|
||||
{
|
||||
title: 'Create a new key',
|
||||
detail: 'Click the "Create Key" button. Name it anything you like (e.g. "OpenSwarm").',
|
||||
},
|
||||
{
|
||||
title: 'Copy your key',
|
||||
detail: 'Click the copy icon next to your new key. It will start with sk-ant-api03-…',
|
||||
},
|
||||
{
|
||||
title: 'Paste it above & save',
|
||||
detail: 'Paste the key into the field above, then hit Save. You\'re all set!',
|
||||
},
|
||||
];
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const open = useAppSelector((s) => s.settings.modalOpen);
|
||||
@@ -57,17 +86,23 @@ const Settings: React.FC = () => {
|
||||
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
|
||||
const updateError = useAppSelector((s) => s.update.error);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'commands'>('general');
|
||||
const [form, setForm] = useState<AppSettings>({ ...settings });
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [recordingShortcut, setRecordingShortcut] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
const [showApiHelp, setShowApiHelp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchModes());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab('general');
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
setForm({ ...settings });
|
||||
@@ -194,7 +229,7 @@ const Settings: React.FC = () => {
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 660,
|
||||
width: 780,
|
||||
maxHeight: '85vh',
|
||||
bgcolor: c.bg.page,
|
||||
borderRadius: 2,
|
||||
@@ -205,20 +240,39 @@ const Settings: React.FC = () => {
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
py: 0,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
|
||||
Settings
|
||||
</Typography>
|
||||
<IconButton onClick={handleRequestClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5, pb: 0.5 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '1rem' }}>
|
||||
Settings
|
||||
</Typography>
|
||||
<IconButton onClick={handleRequestClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
sx={{
|
||||
minHeight: 36,
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 36,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.muted,
|
||||
px: 1.5,
|
||||
'&.Mui-selected': { color: c.accent.primary, fontWeight: 600 },
|
||||
},
|
||||
'& .MuiTabs-indicator': { backgroundColor: c.accent.primary, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label="General" value="general" disableRipple />
|
||||
<Tab label="Commands" value="commands" disableRipple />
|
||||
</Tabs>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{
|
||||
@@ -230,6 +284,7 @@ const Settings: React.FC = () => {
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}>
|
||||
{activeTab === 'general' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
|
||||
|
||||
{/* ── Agent Defaults ── */}
|
||||
@@ -517,9 +572,95 @@ const Settings: React.FC = () => {
|
||||
|
||||
<Box sx={rowLastSx}>
|
||||
<Typography sx={labelSx}>Anthropic API key</Typography>
|
||||
<Typography sx={{ ...descSx, mb: 1.5 }}>
|
||||
Stored securely in the local database.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography sx={descSx}>
|
||||
Stored securely in the local database.
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
onClick={() => setShowApiHelp((v) => !v)}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.4,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{showApiHelp ? 'Hide guide' : 'How do I get a key?'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showApiHelp} timeout={250}>
|
||||
<Box sx={{
|
||||
mb: 1.5,
|
||||
p: 2,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: `${c.accent.primary}08`,
|
||||
border: `1px solid ${c.accent.primary}20`,
|
||||
}}>
|
||||
{API_KEY_STEPS.map((step, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: i < API_KEY_STEPS.length - 1 ? 1.5 : 0 }}>
|
||||
<Box sx={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${c.accent.primary}15`,
|
||||
color: c.accent.primary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
mt: 0.1,
|
||||
}}>
|
||||
{i + 1}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500, lineHeight: 1.4 }}>
|
||||
{step.title}
|
||||
{step.link && (
|
||||
<Typography
|
||||
component="span"
|
||||
onClick={() => {
|
||||
const w = window as any;
|
||||
if (w.openswarm?.openExternal) {
|
||||
w.openswarm.openExternal(step.link);
|
||||
} else {
|
||||
window.open(step.link, '_blank', 'noopener');
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.75rem',
|
||||
ml: 0.75,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.3,
|
||||
verticalAlign: 'middle',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Open
|
||||
<OpenInNewIcon sx={{ fontSize: 12 }} />
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', lineHeight: 1.4 }}>
|
||||
{step.detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<TextField
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={form.anthropic_api_key ?? ''}
|
||||
@@ -661,8 +802,14 @@ const Settings: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ pt: 2.5, pb: 1 }}>
|
||||
<CommandsContent />
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={handleRequestClose}
|
||||
@@ -688,6 +835,7 @@ const Settings: React.FC = () => {
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
|
||||
<DirectoryBrowser
|
||||
open={browseOpen}
|
||||
|
||||
@@ -257,8 +257,8 @@ const ToolSection: React.FC<ToolSectionProps> = ({
|
||||
const overallPolicy = getCatGroupPolicy(allSectionTools);
|
||||
const categoryCount = CATEGORY_ORDER.filter((cat) => grouped[cat]).length;
|
||||
const sectionDescription = deferred
|
||||
? 'On-demand tools loaded via ToolSearch for planning, scheduling, and extended operations'
|
||||
: 'Built-in Claude Agent SDK tools for file operations, shell commands, and search';
|
||||
? 'On-demand actions loaded via ToolSearch for planning, scheduling, and extended operations'
|
||||
: 'Built-in Claude Agent SDK actions for file operations, shell commands, and search';
|
||||
|
||||
const firstSentence = (desc: string) => {
|
||||
if (!desc) return '';
|
||||
@@ -283,7 +283,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
|
||||
<Box sx={{ flex: 1, minWidth: 0, opacity: enabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>{label}</Typography>
|
||||
<Chip label={`${count} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
<Chip label={`${count} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
{deferred && (
|
||||
<Chip label="on-demand" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
)}
|
||||
@@ -312,8 +312,8 @@ const ToolSection: React.FC<ToolSectionProps> = ({
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
|
||||
<Chip label={`${count} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
|
||||
<Chip label={`${count} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
@@ -441,12 +441,12 @@ const Tools: React.FC = () => {
|
||||
} else if (existing && existing.enabled === false) {
|
||||
await dispatch(updateTool({ id: existing.id, enabled: true }));
|
||||
if (integration.authType === 'oauth2' && existing.auth_status !== 'connected') {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — connect your account to discover tools` });
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — connect your account to discover actions` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — re-discovering tools…` });
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — re-discovering actions…` });
|
||||
const discoverResult = await dispatch(discoverTools(existing.id));
|
||||
if (discoverTools.fulfilled.match(discoverResult)) {
|
||||
setSnackbar({ open: true, message: `${integration.name} ready — tools discovered` });
|
||||
setSnackbar({ open: true, message: `${integration.name} ready — actions discovered` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `${integration.name} enabled but discovery failed`, severity: 'error' });
|
||||
}
|
||||
@@ -464,12 +464,12 @@ const Tools: React.FC = () => {
|
||||
if (createTool.fulfilled.match(result)) {
|
||||
const newTool = result.payload;
|
||||
if (integration.authType === 'oauth2') {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — connect your account to discover tools` });
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — connect your account to discover actions` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — discovering tools…` });
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name} — discovering actions…` });
|
||||
const discoverResult = await dispatch(discoverTools(newTool.id));
|
||||
if (discoverTools.fulfilled.match(discoverResult)) {
|
||||
setSnackbar({ open: true, message: `${integration.name} ready — tools discovered` });
|
||||
setSnackbar({ open: true, message: `${integration.name} ready — actions discovered` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `${integration.name} enabled but discovery failed — is ${integration.mcp_config.command || 'the server'} installed?`, severity: 'error' });
|
||||
}
|
||||
@@ -486,7 +486,7 @@ const Tools: React.FC = () => {
|
||||
try {
|
||||
const result = await dispatch(discoverTools(toolId));
|
||||
if (discoverTools.fulfilled.match(result)) {
|
||||
setSnackbar({ open: true, message: 'Tools discovered successfully' });
|
||||
setSnackbar({ open: true, message: 'Actions discovered successfully' });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: 'Discovery failed — is the MCP server running?', severity: 'error' });
|
||||
}
|
||||
@@ -726,10 +726,10 @@ const Tools: React.FC = () => {
|
||||
}));
|
||||
if (createTool.fulfilled.match(result)) {
|
||||
const newTool = result.payload;
|
||||
setSnackbar({ open: true, message: `Installed "${f.name}" — discovering tools…` });
|
||||
setSnackbar({ open: true, message: `Installed "${f.name}" — discovering actions…` });
|
||||
const discoverResult = await dispatch(discoverTools(newTool.id));
|
||||
if (discoverTools.fulfilled.match(discoverResult)) {
|
||||
setSnackbar({ open: true, message: `${f.name} ready — tools discovered` });
|
||||
setSnackbar({ open: true, message: `${f.name} ready — actions discovered` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `${f.name} installed but discovery failed — the MCP server may need setup first`, severity: 'error' });
|
||||
}
|
||||
@@ -756,7 +756,7 @@ const Tools: React.FC = () => {
|
||||
const afterConnect = async () => {
|
||||
const statusResult = await dispatch(fetchToolStatus(toolId));
|
||||
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
|
||||
setSnackbar({ open: true, message: 'Google account connected! Discovering tools…' });
|
||||
setSnackbar({ open: true, message: 'Google account connected! Discovering actions…' });
|
||||
setExpandedToolId(toolId);
|
||||
dispatch(discoverTools(toolId));
|
||||
} else {
|
||||
@@ -812,7 +812,7 @@ const Tools: React.FC = () => {
|
||||
}));
|
||||
if (updateTool.fulfilled.match(result)) {
|
||||
setCredDialogOpen(false);
|
||||
setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering tools…` });
|
||||
setSnackbar({ open: true, message: `${credDialogIntegration.name} connected! Re-discovering actions…` });
|
||||
dispatch(discoverTools(credDialogToolId));
|
||||
} else {
|
||||
setSnackbar({ open: true, message: 'Failed to save credentials', severity: 'error' });
|
||||
@@ -854,8 +854,8 @@ const Tools: React.FC = () => {
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Tool Library</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>Define and manage custom tools for your Claude Code agents.</Typography>
|
||||
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 700, mb: 0.5 }}>Action Library</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>Define and manage custom actions for your Claude Code agents.</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
@@ -865,7 +865,7 @@ const Tools: React.FC = () => {
|
||||
onClick={handleMenuOpen}
|
||||
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
New Tool
|
||||
New Action
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
@@ -893,7 +893,7 @@ const Tools: React.FC = () => {
|
||||
>
|
||||
{builtinSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
|
||||
<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
|
||||
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in Tool Sets</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in Action Sets</Typography>
|
||||
<Chip label={coreTools.length + deferredTools.length + outputs.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
|
||||
</Box>
|
||||
<Collapse in={builtinSectionOpen}>
|
||||
@@ -901,15 +901,15 @@ const Tools: React.FC = () => {
|
||||
|
||||
{/* Core Tools */}
|
||||
{coreTools.length > 0 && (
|
||||
<ToolSection label="Core Tools" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(coreTools, v)} />
|
||||
<ToolSection label="Core Actions" icon={<LockIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={coreTools.length} open={coreSectionOpen} onToggle={() => setCoreSectionOpen((v) => !v)} grouped={groupedCore} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={coreSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(coreTools, v)} />
|
||||
)}
|
||||
|
||||
{/* Extended Tools */}
|
||||
{deferredTools.length > 0 && (
|
||||
<ToolSection label="Extended Tools" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(deferredTools, v)} />
|
||||
<ToolSection label="Extended Actions" icon={<HourglassEmptyIcon sx={{ fontSize: 14, color: c.text.tertiary }} />} count={deferredTools.length} open={deferredSectionOpen} onToggle={() => setDeferredSectionOpen((v) => !v)} grouped={groupedDeferred} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} expandedBuiltin={expandedBuiltin} toggleBuiltinExpand={toggleBuiltinExpand} deferred builtinPermissions={builtinPermissions} onPermissionChange={handleBuiltinPermissionChange} onCategoryPermissionChange={handleBuiltinCategoryPermissionChange} enabled={deferredSectionEnabled} onEnabledChange={(v) => handleSectionEnabledChange(deferredTools, v)} />
|
||||
)}
|
||||
|
||||
{/* Views */}
|
||||
{/* Apps */}
|
||||
{outputs.length > 0 && (
|
||||
<Card sx={{ bgcolor: c.bg.surface, border: `1px solid ${viewsSectionOpen && viewsSectionEnabled ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: c.accent.primary, boxShadow: '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 2, '&:last-child': { pb: 1.5 } }}>
|
||||
@@ -926,10 +926,10 @@ const Tools: React.FC = () => {
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0, opacity: viewsSectionEnabled ? 1 : 0.4, transition: 'opacity 0.2s' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>Views</Typography>
|
||||
<Chip label={`${outputs.length} view${outputs.length !== 1 ? 's' : ''}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.95rem' }}>Apps</Typography>
|
||||
<Chip label={`${outputs.length} app${outputs.length !== 1 ? 's' : ''}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
</Box>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Dashboard views and data displays for your agent</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>Dashboard apps and data displays for your agent</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
@@ -1001,7 +1001,7 @@ const Tools: React.FC = () => {
|
||||
<Box onClick={() => setCustomSectionOpen((v) => !v)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}>
|
||||
{customSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
|
||||
<BuildIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
|
||||
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Custom Tool Sets</Typography>
|
||||
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Custom Action Sets</Typography>
|
||||
<Chip label={tools.length + uninstalledIntegrations.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
|
||||
</Box>
|
||||
<Collapse in={customSectionOpen}>
|
||||
@@ -1010,7 +1010,7 @@ const Tools: React.FC = () => {
|
||||
) : (tools.length === 0 && uninstalledIntegrations.length === 0) ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6, color: c.text.ghost, gap: 1.5 }}>
|
||||
<BuildIcon sx={{ fontSize: 40, opacity: 0.3 }} />
|
||||
<Typography sx={{ fontSize: '0.9rem' }}>No custom tools defined yet. Create one to get started.</Typography>
|
||||
<Typography sx={{ fontSize: '0.9rem' }}>No custom actions defined yet. Create one to get started.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
|
||||
@@ -1199,7 +1199,7 @@ const Tools: React.FC = () => {
|
||||
<Chip icon={<SettingsIcon sx={{ fontSize: 12 }} />} label="Configured" size="small" sx={{ bgcolor: c.status.warningBg, color: c.status.warning, fontSize: '0.7rem', height: 20, '& .MuiChip-icon': { color: c.status.warning } }} />
|
||||
)}
|
||||
{ig && totalToolCount > 0 && (
|
||||
<Chip label={`${totalToolCount} tools`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
<Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', height: 20, '& .MuiChip-label': { px: 0.6 } }} />
|
||||
)}
|
||||
{ig && (
|
||||
<Chip component="a" href={ig.website} target="_blank" rel="noopener" clickable icon={<OpenInNewIcon sx={{ fontSize: 10 }} />} label="docs" size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, '& .MuiChip-label': { px: 0.4 }, '& .MuiChip-icon': { ml: 0.4, fontSize: 10 } }} />
|
||||
@@ -1274,13 +1274,13 @@ const Tools: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<SecurityIcon sx={{ fontSize: 14, color: c.text.muted }} />
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Tool Permissions</Typography>
|
||||
{hasPerms && <Chip label={`${totalToolCount} tools`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />}
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Action Permissions</Typography>
|
||||
{hasPerms && <Chip label={`${totalToolCount} actions`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.ghost, fontSize: '0.65rem', height: 18, ml: 0.5, '& .MuiChip-label': { px: 0.6 } }} />}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{hasPerms && (
|
||||
<>
|
||||
<Tooltip title="Allow all read-only tools">
|
||||
<Tooltip title="Allow all read-only actions">
|
||||
<Button size="small" onClick={() => handleBulkReadOnly(tool.id)} sx={{ color: c.status.info, textTransform: 'none', fontSize: '0.7rem', minWidth: 'auto', px: 1, py: 0.25 }}>
|
||||
Allow reads
|
||||
</Button>
|
||||
@@ -1292,7 +1292,7 @@ const Tools: React.FC = () => {
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip title="Discover / refresh tools from MCP server">
|
||||
<Tooltip title="Discover / refresh actions from MCP server">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDiscover(tool.id)}
|
||||
@@ -1308,7 +1308,7 @@ const Tools: React.FC = () => {
|
||||
{!hasPerms ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 3, gap: 1.5 }}>
|
||||
<ExtensionIcon sx={{ fontSize: 28, color: c.text.ghost, opacity: 0.4 }} />
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem' }}>No tools discovered yet</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem' }}>No actions discovered yet</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -1317,10 +1317,10 @@ const Tools: React.FC = () => {
|
||||
disabled={discovering || !canDiscover}
|
||||
sx={{ borderColor: c.border.medium, color: c.text.secondary, '&:hover': { borderColor: c.accent.primary, color: c.accent.primary }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5 }}
|
||||
>
|
||||
Discover Tools
|
||||
Discover Actions
|
||||
</Button>
|
||||
{!canDiscover && (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable tool discovery</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.72rem' }}>Add an MCP configuration to enable action discovery</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -365,7 +365,7 @@ const ConsolePanel: React.FC<ConsolePanelProps> = ({ entry, c }) => {
|
||||
|
||||
{!entry.stdout && !entry.stderr && !entry.backendResult && !entry.error && (
|
||||
<Typography sx={{ fontSize: '0.75rem', color: '#8b949e', fontFamily: c.font.mono }}>
|
||||
No backend code to execute. Only input data was sent to the view.
|
||||
No backend code to execute. Only input data was sent to the app.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -735,7 +735,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
delete outputFiles['schema.json'];
|
||||
|
||||
return {
|
||||
name: name || 'Untitled View',
|
||||
name: name || 'Untitled App',
|
||||
description,
|
||||
icon: 'view_quilt',
|
||||
input_schema: schema,
|
||||
@@ -1143,7 +1143,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
<TextField
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="View name"
|
||||
placeholder="App name"
|
||||
variant="standard"
|
||||
sx={{
|
||||
flex: 1,
|
||||
@@ -1554,7 +1554,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
{autoRunEnabled ? (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', p: 2, gap: 1.5, overflow: 'hidden' }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem', lineHeight: 1.6, flexShrink: 0 }}>
|
||||
Describe what data to generate for this view. When triggered, an LLM will produce input data matching your schema and populate the preview.
|
||||
Describe what data to generate for this app. When triggered, an LLM will produce input data matching your schema and populate the preview.
|
||||
</Typography>
|
||||
<ChatInput
|
||||
ref={autoRunInputRef}
|
||||
@@ -1597,7 +1597,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5 }}>
|
||||
<BoltIcon sx={{ fontSize: 40, color: c.text.ghost, opacity: 0.3 }} />
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.88rem' }}>
|
||||
Enable Auto Run to generate live data for this view
|
||||
Enable Auto Run to generate live data for this app
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.78rem', maxWidth: 360, textAlign: 'center', lineHeight: 1.5 }}>
|
||||
Configure a prompt that describes what data to generate. An LLM will produce input matching your schema and populate the preview automatically.
|
||||
|
||||
@@ -154,7 +154,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
|
||||
background: '#fff',
|
||||
...style,
|
||||
}}
|
||||
title="View Preview"
|
||||
title="App Preview"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -13,8 +14,11 @@ import ViewRunDialog from './ViewRunDialog';
|
||||
const Views: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { id: routeId } = useParams<{ id: string }>();
|
||||
const items = useAppSelector((state) => state.outputs.items);
|
||||
const loading = useAppSelector((state) => state.outputs.loading);
|
||||
const loaded = useAppSelector((state) => state.outputs.loaded);
|
||||
const outputs = useMemo(() => Object.values(items), [items]);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -25,14 +29,25 @@ const Views: React.FC = () => {
|
||||
dispatch(fetchOutputs());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
if (routeId === 'new') {
|
||||
setEditingOutput(null);
|
||||
setEditorOpen(true);
|
||||
} else if (routeId && items[routeId]) {
|
||||
setEditingOutput(items[routeId]);
|
||||
setEditorOpen(true);
|
||||
} else if (routeId && routeId !== 'new') {
|
||||
navigate('/apps', { replace: true });
|
||||
}
|
||||
}, [routeId, loaded, items, navigate]);
|
||||
|
||||
const handleNewView = () => {
|
||||
setEditingOutput(null);
|
||||
setEditorOpen(true);
|
||||
navigate('/apps/new');
|
||||
};
|
||||
|
||||
const handleEditView = (output: Output) => {
|
||||
setEditingOutput(output);
|
||||
setEditorOpen(true);
|
||||
navigate(`/apps/${output.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteView = (id: string) => {
|
||||
@@ -43,6 +58,7 @@ const Views: React.FC = () => {
|
||||
setEditorOpen(false);
|
||||
setEditingOutput(null);
|
||||
dispatch(fetchOutputs());
|
||||
navigate('/apps');
|
||||
};
|
||||
|
||||
if (editorOpen) {
|
||||
@@ -66,12 +82,17 @@ const Views: React.FC = () => {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{ fontWeight: 700, color: c.text.primary }}
|
||||
>
|
||||
Views
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{ fontWeight: 700, color: c.text.primary }}
|
||||
>
|
||||
Apps
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem', mt: 0.5 }}>
|
||||
In the past, we used to have to pay for expensive applications. Now, you can prompt them into existence.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
@@ -85,7 +106,7 @@ const Views: React.FC = () => {
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
}}
|
||||
>
|
||||
New view
|
||||
New app
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -103,10 +124,10 @@ const Views: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '1.1rem', mb: 1 }}>
|
||||
No views yet
|
||||
No apps yet
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.tertiary }}>
|
||||
Create your first reusable view
|
||||
Create your first reusable app
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -107,14 +107,14 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
|
||||
}
|
||||
|
||||
async function handleBrowserCommand(data: Record<string, any>) {
|
||||
const { request_id, action, browser_id, params = {} } = data;
|
||||
const { request_id, action, browser_id, tab_id, params = {} } = data;
|
||||
if (!request_id) return;
|
||||
|
||||
const wv = getWebview(browser_id);
|
||||
const wv = getWebview(browser_id, tab_id || undefined);
|
||||
if (!wv) {
|
||||
dashboardWs.send('browser:result', {
|
||||
request_id,
|
||||
error: `Browser card '${browser_id}' not found or not an Electron webview`,
|
||||
error: `Browser card '${browser_id}'${tab_id ? ` tab '${tab_id}'` : ''} not found or not an Electron webview`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,19 +19,42 @@ export interface BrowserWebview extends HTMLElement {
|
||||
}
|
||||
|
||||
const registry = new Map<string, BrowserWebview>();
|
||||
const activeTabMap = new Map<string, string>();
|
||||
|
||||
export function registerWebview(browserId: string, wv: BrowserWebview): void {
|
||||
registry.set(browserId, wv);
|
||||
function makeKey(browserId: string, tabId: string): string {
|
||||
return `${browserId}:${tabId}`;
|
||||
}
|
||||
|
||||
export function unregisterWebview(browserId: string): void {
|
||||
registry.delete(browserId);
|
||||
export function registerWebview(browserId: string, tabId: string, wv: BrowserWebview): void {
|
||||
registry.set(makeKey(browserId, tabId), wv);
|
||||
}
|
||||
|
||||
export function getWebview(browserId: string): BrowserWebview | undefined {
|
||||
return registry.get(browserId);
|
||||
export function unregisterWebview(browserId: string, tabId: string): void {
|
||||
registry.delete(makeKey(browserId, tabId));
|
||||
}
|
||||
|
||||
export function setActiveTab(browserId: string, tabId: string): void {
|
||||
activeTabMap.set(browserId, tabId);
|
||||
}
|
||||
|
||||
export function getWebview(browserId: string, tabId?: string): BrowserWebview | undefined {
|
||||
const resolvedTabId = tabId || activeTabMap.get(browserId);
|
||||
if (!resolvedTabId) return undefined;
|
||||
return registry.get(makeKey(browserId, resolvedTabId));
|
||||
}
|
||||
|
||||
export function getActiveTabId(browserId: string): string | undefined {
|
||||
return activeTabMap.get(browserId);
|
||||
}
|
||||
|
||||
export function getAllWebviews(): Map<string, BrowserWebview> {
|
||||
return new Map(registry);
|
||||
}
|
||||
|
||||
export function unregisterAllForBrowser(browserId: string): void {
|
||||
const prefix = `${browserId}:`;
|
||||
for (const key of registry.keys()) {
|
||||
if (key.startsWith(prefix)) registry.delete(key);
|
||||
}
|
||||
activeTabMap.delete(browserId);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
||||
|
||||
export const DEFAULT_CARD_W = 480;
|
||||
export const DEFAULT_CARD_H = 280;
|
||||
export const DEFAULT_VIEW_CARD_W = 480;
|
||||
export const DEFAULT_VIEW_CARD_H = 360;
|
||||
export const DEFAULT_BROWSER_CARD_W = 640;
|
||||
export const DEFAULT_BROWSER_CARD_H = 480;
|
||||
export const DEFAULT_VIEW_CARD_W = 1280;
|
||||
export const DEFAULT_VIEW_CARD_H = 800;
|
||||
export const DEFAULT_BROWSER_CARD_W = 1280;
|
||||
export const DEFAULT_BROWSER_CARD_H = 800;
|
||||
export const EXPANDED_CARD_MIN_H = 620;
|
||||
const GRID_GAP = 24;
|
||||
const GRID_ORIGIN = { x: 40, y: 100 };
|
||||
const GRID_COLS_FALLBACK = 4;
|
||||
@@ -30,9 +31,18 @@ export interface ViewCardPosition {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BrowserTab {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
export interface BrowserCardPosition {
|
||||
browser_id: string;
|
||||
url: string;
|
||||
tabs: BrowserTab[];
|
||||
activeTabId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -66,23 +76,39 @@ interface LayoutPayload {
|
||||
expandedSessionIds: string[];
|
||||
}
|
||||
|
||||
function generateTabId(): string {
|
||||
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
export const fetchLayout = createAsyncThunk(
|
||||
'dashboardLayout/fetch',
|
||||
async (dashboardId: string) => {
|
||||
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
|
||||
const data = await res.json();
|
||||
const layout = data.layout ?? {};
|
||||
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
|
||||
|
||||
for (const card of Object.values(browserCards)) {
|
||||
if (!card.tabs || card.tabs.length === 0) {
|
||||
const tabId = generateTabId();
|
||||
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
|
||||
card.activeTabId = tabId;
|
||||
}
|
||||
if (!card.url && card.tabs.length > 0) {
|
||||
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
|
||||
card.url = active.url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
|
||||
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
|
||||
browserCards: (layout.browser_cards ?? {}) as Record<string, BrowserCardPosition>,
|
||||
browserCards: browserCards as Record<string, BrowserCardPosition>,
|
||||
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
|
||||
} satisfies LayoutPayload;
|
||||
},
|
||||
);
|
||||
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
interface SaveLayoutPayload extends LayoutPayload {
|
||||
dashboardId: string;
|
||||
}
|
||||
@@ -90,31 +116,56 @@ interface SaveLayoutPayload extends LayoutPayload {
|
||||
export const saveLayout = createAsyncThunk(
|
||||
'dashboardLayout/save',
|
||||
async (payload: SaveLayoutPayload) => {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
return new Promise<SaveLayoutPayload>((resolve) => {
|
||||
saveTimeout = setTimeout(async () => {
|
||||
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
layout: {
|
||||
cards: payload.cards,
|
||||
view_cards: payload.viewCards,
|
||||
browser_cards: payload.browserCards,
|
||||
expanded_session_ids: payload.expandedSessionIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
resolve(payload);
|
||||
}, 500);
|
||||
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
layout: {
|
||||
cards: payload.cards,
|
||||
view_cards: payload.viewCards,
|
||||
browser_cards: payload.browserCards,
|
||||
expanded_session_ids: payload.expandedSessionIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return payload;
|
||||
},
|
||||
);
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function rectsOverlap(a: Rect, b: Rect): boolean {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
|
||||
function collectOccupiedRects(
|
||||
state: DashboardLayoutState,
|
||||
expandedSessionIds?: string[],
|
||||
): Rect[] {
|
||||
const expanded = new Set(expandedSessionIds);
|
||||
const rects: Rect[] = [];
|
||||
for (const c of Object.values(state.cards)) {
|
||||
const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height;
|
||||
rects.push({ x: c.x, y: c.y, w: c.width, h });
|
||||
}
|
||||
for (const c of Object.values(state.viewCards)) {
|
||||
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
|
||||
}
|
||||
for (const c of Object.values(state.browserCards)) {
|
||||
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
export function findOpenGridCell(
|
||||
existing: Record<string, { x: number; y: number }>,
|
||||
excludeIds: Set<string>,
|
||||
extraOccupied?: Record<string, { x: number; y: number }>,
|
||||
occupiedRects: Rect[],
|
||||
newW: number,
|
||||
newH: number,
|
||||
): { x: number; y: number } {
|
||||
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
||||
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
||||
@@ -123,28 +174,13 @@ export function findOpenGridCell(
|
||||
Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK,
|
||||
);
|
||||
|
||||
const occupied = new Set<string>();
|
||||
for (const [id, card] of Object.entries(existing)) {
|
||||
if (excludeIds.has(id)) continue;
|
||||
const col = Math.round((card.x - GRID_ORIGIN.x) / cellW);
|
||||
const row = Math.round((card.y - GRID_ORIGIN.y) / cellH);
|
||||
occupied.add(`${col},${row}`);
|
||||
}
|
||||
if (extraOccupied) {
|
||||
for (const card of Object.values(extraOccupied)) {
|
||||
const col = Math.round((card.x - GRID_ORIGIN.x) / cellW);
|
||||
const row = Math.round((card.y - GRID_ORIGIN.y) / cellH);
|
||||
occupied.add(`${col},${row}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; ; row++) {
|
||||
for (let col = 0; col < maxCols; col++) {
|
||||
if (!occupied.has(`${col},${row}`)) {
|
||||
return {
|
||||
x: GRID_ORIGIN.x + col * cellW,
|
||||
y: GRID_ORIGIN.y + row * cellH,
|
||||
};
|
||||
const x = GRID_ORIGIN.x + col * cellW;
|
||||
const y = GRID_ORIGIN.y + row * cellH;
|
||||
const candidate: Rect = { x, y, w: newW, h: newH };
|
||||
if (!occupiedRects.some((r) => rectsOverlap(candidate, r))) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,8 +218,12 @@ const dashboardLayoutSlice = createSlice({
|
||||
delete state.cards[action.payload];
|
||||
},
|
||||
|
||||
reconcileSessions(state, action: PayloadAction<string[]>) {
|
||||
const liveIds = new Set(action.payload);
|
||||
reconcileSessions(
|
||||
state,
|
||||
action: PayloadAction<{ sessionIds: string[]; expandedSessionIds: string[] }>,
|
||||
) {
|
||||
const { sessionIds, expandedSessionIds } = action.payload;
|
||||
const liveIds = new Set(sessionIds);
|
||||
|
||||
for (const id of Object.keys(state.cards)) {
|
||||
if (!liveIds.has(id)) {
|
||||
@@ -192,14 +232,11 @@ const dashboardLayoutSlice = createSlice({
|
||||
}
|
||||
|
||||
const hasDraftCard = Object.keys(state.cards).some((id) => id.startsWith('draft-'));
|
||||
const extraOccupied: Record<string, { x: number; y: number }> = {
|
||||
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
|
||||
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
|
||||
};
|
||||
const newIds = action.payload.filter((id) => !state.cards[id]);
|
||||
const newIds = sessionIds.filter((id) => !state.cards[id]);
|
||||
for (const id of newIds) {
|
||||
if (hasDraftCard && !id.startsWith('draft-')) continue;
|
||||
const pos = findOpenGridCell(state.cards, new Set(), extraOccupied);
|
||||
const rects = collectOccupiedRects(state, expandedSessionIds);
|
||||
const pos = findOpenGridCell(rects, DEFAULT_CARD_W, DEFAULT_CARD_H);
|
||||
state.cards[id] = {
|
||||
session_id: id,
|
||||
x: pos.x,
|
||||
@@ -210,7 +247,11 @@ const dashboardLayoutSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
tidyLayout(state) {
|
||||
tidyLayout(
|
||||
state,
|
||||
action: PayloadAction<{ expandedSessionIds: string[] }>,
|
||||
) {
|
||||
const expanded = new Set(action.payload.expandedSessionIds);
|
||||
const agentCards = Object.values(state.cards);
|
||||
const viewCards = Object.values(state.viewCards);
|
||||
const bCards = Object.values(state.browserCards);
|
||||
@@ -218,45 +259,46 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (total === 0) return;
|
||||
|
||||
const allItems = [
|
||||
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y })),
|
||||
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedH: c.height })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedH: c.height })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedH: c.height })),
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
||||
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
||||
const placedRects: Rect[] = [];
|
||||
|
||||
const slots: Array<[number, number]> = [];
|
||||
for (let row = 0; row < 3; row++)
|
||||
for (let col = 0; col < 3; col++) slots.push([col, row]);
|
||||
for (let row = 0; row < 3; row++) slots.push([3, row]);
|
||||
for (let col = 0; col < 4; col++) slots.push([col, 3]);
|
||||
for (let row = 4; slots.length < total; row++)
|
||||
for (let col = 0; col < 4 && slots.length < total; col++)
|
||||
slots.push([col, row]);
|
||||
for (const item of allItems) {
|
||||
let w: number, h: number;
|
||||
if (item.kind === 'agent') {
|
||||
w = DEFAULT_CARD_W;
|
||||
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : DEFAULT_CARD_H;
|
||||
} else if (item.kind === 'view') {
|
||||
w = DEFAULT_VIEW_CARD_W; h = DEFAULT_VIEW_CARD_H;
|
||||
} else {
|
||||
w = DEFAULT_BROWSER_CARD_W; h = DEFAULT_BROWSER_CARD_H;
|
||||
}
|
||||
|
||||
const pos = findOpenGridCell(placedRects, w, h);
|
||||
placedRects.push({ x: pos.x, y: pos.y, w, h });
|
||||
|
||||
allItems.forEach((item, i) => {
|
||||
const [col, row] = slots[i];
|
||||
const nx = GRID_ORIGIN.x + col * cellW;
|
||||
const ny = GRID_ORIGIN.y + row * cellH;
|
||||
if (item.kind === 'agent') {
|
||||
const card = state.cards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_CARD_W; card.height = DEFAULT_CARD_H; }
|
||||
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
|
||||
} else if (item.kind === 'view') {
|
||||
const card = state.viewCards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_VIEW_CARD_W; card.height = DEFAULT_VIEW_CARD_H; }
|
||||
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) { card.x = nx; card.y = ny; card.width = DEFAULT_BROWSER_CARD_W; card.height = DEFAULT_BROWSER_CARD_H; }
|
||||
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
addViewCard(state, action: PayloadAction<{ outputId: string }>) {
|
||||
const { outputId } = action.payload;
|
||||
addViewCard(state, action: PayloadAction<{ outputId: string; expandedSessionIds?: string[] }>) {
|
||||
const { outputId, expandedSessionIds } = action.payload;
|
||||
if (state.viewCards[outputId]) return;
|
||||
const pos = findOpenGridCell(state.cards, new Set(), state.viewCards);
|
||||
const rects = collectOccupiedRects(state, expandedSessionIds);
|
||||
const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H);
|
||||
state.viewCards[outputId] = {
|
||||
output_id: outputId,
|
||||
x: pos.x,
|
||||
@@ -291,16 +333,16 @@ const dashboardLayoutSlice = createSlice({
|
||||
delete state.viewCards[action.payload];
|
||||
},
|
||||
|
||||
addBrowserCard(state, action: PayloadAction<{ url: string }>) {
|
||||
addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) {
|
||||
const id = `browser-${Date.now().toString(36)}`;
|
||||
const allOccupied: Record<string, { x: number; y: number }> = {
|
||||
...Object.fromEntries(Object.values(state.viewCards).map((c) => [c.output_id, c])),
|
||||
...Object.fromEntries(Object.values(state.browserCards).map((c) => [c.browser_id, c])),
|
||||
};
|
||||
const pos = findOpenGridCell(state.cards, new Set(), allOccupied);
|
||||
const tabId = generateTabId();
|
||||
const rects = collectOccupiedRects(state, action.payload.expandedSessionIds);
|
||||
const pos = findOpenGridCell(rects, DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H);
|
||||
state.browserCards[id] = {
|
||||
browser_id: id,
|
||||
url: action.payload.url,
|
||||
tabs: [{ id: tabId, url: action.payload.url, title: '' }],
|
||||
activeTabId: tabId,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: DEFAULT_BROWSER_CARD_W,
|
||||
@@ -337,9 +379,106 @@ const dashboardLayoutSlice = createSlice({
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; url: string }>
|
||||
) {
|
||||
const { browserId, url } = action.payload;
|
||||
const card = state.browserCards[browserId];
|
||||
if (card) { card.url = url; }
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (card) {
|
||||
card.url = action.payload.url;
|
||||
const tab = card.tabs.find((t) => t.id === card.activeTabId);
|
||||
if (tab) tab.url = action.payload.url;
|
||||
}
|
||||
},
|
||||
|
||||
addBrowserTab(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; url: string; makeActive?: boolean }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const tabId = generateTabId();
|
||||
card.tabs.push({ id: tabId, url: action.payload.url, title: '' });
|
||||
if (action.payload.makeActive !== false) {
|
||||
card.activeTabId = tabId;
|
||||
card.url = action.payload.url;
|
||||
}
|
||||
},
|
||||
|
||||
removeBrowserTab(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const idx = card.tabs.findIndex((t) => t.id === action.payload.tabId);
|
||||
if (idx === -1) return;
|
||||
card.tabs.splice(idx, 1);
|
||||
if (card.tabs.length === 0) {
|
||||
delete state.browserCards[action.payload.browserId];
|
||||
return;
|
||||
}
|
||||
if (card.activeTabId === action.payload.tabId) {
|
||||
const newActive = card.tabs[Math.min(idx, card.tabs.length - 1)];
|
||||
card.activeTabId = newActive.id;
|
||||
card.url = newActive.url;
|
||||
}
|
||||
},
|
||||
|
||||
setActiveBrowserTab(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
||||
if (tab) {
|
||||
card.activeTabId = tab.id;
|
||||
card.url = tab.url;
|
||||
}
|
||||
},
|
||||
|
||||
updateBrowserTabUrl(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string; url: string }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
||||
if (tab) {
|
||||
tab.url = action.payload.url;
|
||||
if (action.payload.tabId === card.activeTabId) {
|
||||
card.url = action.payload.url;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateBrowserTabTitle(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string; title: string }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
||||
if (tab) tab.title = action.payload.title;
|
||||
},
|
||||
|
||||
updateBrowserTabFavicon(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string; favicon: string }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
||||
if (tab) tab.favicon = action.payload.favicon;
|
||||
},
|
||||
|
||||
reorderBrowserTab(
|
||||
state,
|
||||
action: PayloadAction<{ browserId: string; tabId: string; toIndex: number }>
|
||||
) {
|
||||
const card = state.browserCards[action.payload.browserId];
|
||||
if (!card) return;
|
||||
const fromIdx = card.tabs.findIndex((t) => t.id === action.payload.tabId);
|
||||
if (fromIdx === -1) return;
|
||||
const [tab] = card.tabs.splice(fromIdx, 1);
|
||||
card.tabs.splice(Math.max(0, Math.min(action.payload.toIndex, card.tabs.length)), 0, tab);
|
||||
},
|
||||
|
||||
moveCards(
|
||||
@@ -461,6 +600,13 @@ export const {
|
||||
setBrowserCardSize,
|
||||
removeBrowserCard,
|
||||
updateBrowserCardUrl,
|
||||
addBrowserTab,
|
||||
removeBrowserTab,
|
||||
setActiveBrowserTab,
|
||||
updateBrowserTabUrl,
|
||||
updateBrowserTabTitle,
|
||||
updateBrowserTabFavicon,
|
||||
reorderBrowserTab,
|
||||
moveCards,
|
||||
setGlowingBrowserCards,
|
||||
clearGlowingBrowserCards,
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Dashboard {
|
||||
auto_named: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
thumbnail?: string | null;
|
||||
}
|
||||
|
||||
interface DashboardsState {
|
||||
@@ -67,6 +68,20 @@ export const duplicateDashboard = createAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export const updateDashboardThumbnail = createAsyncThunk(
|
||||
'dashboards/updateThumbnail',
|
||||
async ({ id, thumbnail }: { id: string; thumbnail: string }) => {
|
||||
const res = await fetch(`${DASHBOARDS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ thumbnail }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Thumbnail update failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { id, thumbnail: data.thumbnail as string | null, updated_at: data.updated_at as string };
|
||||
},
|
||||
);
|
||||
|
||||
export const generateDashboardName = createAsyncThunk(
|
||||
'dashboards/generateName',
|
||||
async (dashboardId: string) => {
|
||||
@@ -124,6 +139,13 @@ const dashboardsSlice = createSlice({
|
||||
state.items[id].name = name;
|
||||
state.items[id].auto_named = auto_named;
|
||||
}
|
||||
})
|
||||
.addCase(updateDashboardThumbnail.fulfilled, (state, action) => {
|
||||
const { id, thumbnail, updated_at } = action.payload;
|
||||
if (state.items[id]) {
|
||||
state.items[id].thumbnail = thumbnail;
|
||||
state.items[id].updated_at = updated_at;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user