From e6f1e1805490ccbcdd6744062513a62c2900bfb0 Mon Sep 17 00:00:00 2001 From: haikdc Date: Mon, 16 Mar 2026 03:08:58 -0700 Subject: [PATCH] [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) --- backend/apps/agents/agent_manager.py | 1 + backend/apps/agents/browser_mcp_server.py | 25 +- backend/apps/agents/ws_manager.py | 3 +- backend/apps/dashboards/dashboards.py | 3 + backend/apps/dashboards/models.py | 2 + backend/apps/modes/modes.py | 6 +- backend/main.py | 3 +- electron/main.js | 13 +- electron/package-lock.json | 4 +- electron/package.json | 2 +- electron/preload.js | 2 + frontend/src/app/Main.tsx | 9 +- frontend/src/app/components/CommandPicker.tsx | 12 +- .../app/components/KeyboardShortcutsHelp.tsx | 2 +- .../src/app/components/Layout/AppShell.tsx | 365 ++++++++-- .../src/app/components/RichPromptEditor.tsx | 2 +- .../src/app/pages/AgentChat/ChatInput.tsx | 2 +- .../src/app/pages/AgentChat/MessageBubble.tsx | 2 +- .../src/app/pages/AgentChat/ViewBubble.tsx | 2 +- frontend/src/app/pages/Commands/Commands.tsx | 70 +- .../app/pages/Customization/Customization.tsx | 115 +++ .../src/app/pages/Dashboard/AgentCard.tsx | 2 +- .../src/app/pages/Dashboard/BrowserCard.tsx | 672 +++++++++++++----- .../src/app/pages/Dashboard/Dashboard.tsx | 122 +++- .../app/pages/Dashboard/DashboardToolbar.tsx | 4 +- .../app/pages/Dashboard/DashboardViewCard.tsx | 2 +- .../Dashboard/captureDashboardThumbnail.ts | 40 ++ .../app/pages/Dashboard/useCanvasControls.ts | 28 +- .../DashboardSelection/DashboardSelection.tsx | 31 +- frontend/src/app/pages/Modes/Modes.tsx | 16 +- frontend/src/app/pages/Settings/Settings.tsx | 178 ++++- frontend/src/app/pages/Tools/Tools.tsx | 72 +- frontend/src/app/pages/Views/ViewEditor.tsx | 10 +- frontend/src/app/pages/Views/ViewPreview.tsx | 2 +- frontend/src/app/pages/Views/Views.tsx | 47 +- frontend/src/shared/browserCommandHandler.ts | 6 +- frontend/src/shared/browserRegistry.ts | 35 +- .../src/shared/state/dashboardLayoutSlice.ts | 324 ++++++--- frontend/src/shared/state/dashboardsSlice.ts | 22 + 39 files changed, 1733 insertions(+), 525 deletions(-) create mode 100644 frontend/src/app/pages/Customization/Customization.tsx create mode 100644 frontend/src/app/pages/Dashboard/captureDashboardThumbnail.ts 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, + }} + /> + )) ) : (