[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:
haikdc
2026-03-16 03:08:58 -07:00
parent 08cab727a1
commit e6f1e18054
39 changed files with 1733 additions and 525 deletions
+1
View File
@@ -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])],
+19 -6
View File
@@ -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}
+2 -1
View File
@@ -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,
})
+3
View File
@@ -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")
+2
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
});
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.01",
"version": "1.0.2",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
+2
View File
@@ -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'),
+5 -4
View File
@@ -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' },
+317 -48
View File
@@ -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();
+19 -51
View File
@@ -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);
+496 -176
View File
@@ -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);
+97 -25
View File
@@ -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={{
+8 -8
View File
@@ -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) => (
+163 -15
View File
@@ -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}
+36 -36
View File
@@ -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>
) : (
+5 -5
View File
@@ -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.
+1 -1
View File
@@ -154,7 +154,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
background: '#fff',
...style,
}}
title="View Preview"
title="App Preview"
/>
</Box>
);
+34 -13
View File
@@ -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>
) : (
+3 -3
View File
@@ -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;
}
+29 -6
View File
@@ -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);
}
+235 -89
View File
@@ -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;
}
});
},
});