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