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'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import Collapse from '@mui/material/Collapse'; import Button from '@mui/material/Button'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; import InputBase from '@mui/material/InputBase'; import DashboardIcon from '@mui/icons-material/Dashboard'; 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 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'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; import CloseIcon from '@mui/icons-material/Close'; import LinearProgress from '@mui/material/LinearProgress'; import Settings from '@/app/pages/Settings/Settings'; import DynamicIsland from '@/app/components/DynamicIsland'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; const SIDEBAR_MIN = 160; const SIDEBAR_MAX = 400; const SIDEBAR_DEFAULT = 220; const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width'; const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; const CUSTOMIZATION_ITEMS = [ { label: 'Prompts', path: '/templates', icon: }, { label: 'Skills', path: '/skills', icon: }, { label: 'Actions', path: '/actions', icon: }, { label: 'Modes', path: '/modes', 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(true); const [appsExpanded, setAppsExpanded] = useState(true); const [customizationExpanded, setCustomizationExpanded] = useState(true); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [renamingDashboardId, setRenamingDashboardId] = useState(null); const [renameValue, setRenameValue] = useState(''); 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); const downloadPercent = useAppSelector((state) => state.update.downloadPercent); const [dismissedVersion, setDismissedVersion] = useState(() => { try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } }); const [snackbarDismissed, setSnackbarDismissed] = useState(false); const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion; const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion; const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed; const handleDismissBanner = useCallback(() => { if (availableVersion) { try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {} setDismissedVersion(availableVersion); } }, [availableVersion]); const handleDownloadUpdate = useCallback(async () => { try { await (window as any).openswarm?.downloadUpdate(); } catch {} }, []); const handleInstallUpdate = useCallback(() => { (window as any).openswarm?.installUpdate(); }, []); const dashboardItems = useAppSelector((state) => state.dashboards.items); const dashboardList = Object.values(dashboardItems).sort( (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]); const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => { const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/); if (dashMatch) { if (webContentsId != null) { const browserId = findBrowserByWebContentsId(webContentsId); if (browserId) { dispatch(addBrowserTab({ browserId, url, makeActive: true })); return; } } dispatch(addBrowserCard({ url })); } else { dispatch(setPendingBrowserUrl(url)); const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined; const firstDashboard = dashboardList[0]; const targetId = lastId || firstDashboard?.id; if (targetId) { navigate(`/dashboard/${targetId}`); } else { dispatch(createDashboard('Untitled Dashboard')).then((result: any) => { if (createDashboard.fulfilled.match(result)) { navigate(`/dashboard/${result.payload.id}`); } }); } } }, [location.pathname, dashboardList, dispatch, navigate]); useEffect(() => { let lastUrl = ''; let lastTime = 0; const handleClick = (e: MouseEvent) => { const anchor = (e.target as HTMLElement)?.closest?.('a'); if (!anchor) return; const href = anchor.getAttribute('href'); if (!href) return; if (!/^https?:\/\//i.test(href)) return; if (href.startsWith('http://localhost:')) return; e.preventDefault(); e.stopPropagation(); const now = Date.now(); if (href === lastUrl && now - lastTime < 1000) return; lastUrl = href; lastTime = now; openUrlInBrowser(href); }; document.addEventListener('click', handleClick, true); return () => document.removeEventListener('click', handleClick, true); }, [openUrlInBrowser]); useEffect(() => { const w = window as any; if (!w.openswarm?.onWebviewNewWindow) return; let lastUrl = ''; let lastTime = 0; return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => { const now = Date.now(); if (url === lastUrl && now - lastTime < 1000) return; lastUrl = url; lastTime = now; openUrlInBrowser(url, webContentsId); }); }, [openUrlInBrowser]); 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 === '/') { setDashboardsExpanded((prev) => !prev); } else { navigate('/'); setDashboardsExpanded(true); } }; const handleDashboardItemClick = (dashboardId: string) => { if (renamingDashboardId === dashboardId) return; navigate(`/dashboard/${dashboardId}`); }; const handleStartDashboardRename = (id: string, currentName: string) => { setRenamingDashboardId(id); setRenameValue(currentName); }; const handleDashboardRenameSubmit = (id: string) => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== dashboardItems[id]?.name) { dispatch(renameDashboard({ id, name: trimmed })); } setRenamingDashboardId(null); }; const handleCreateDashboard = async (e: React.MouseEvent) => { e.stopPropagation(); const result = await dispatch(createDashboard('Untitled Dashboard')); if (createDashboard.fulfilled.match(result)) { navigate(`/dashboard/${result.payload.id}`); } }; 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 */} setSidebarCollapsed((prev) => !prev)} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, p: 0.5, borderRadius: 1, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, }} > navigate(-1)} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, p: 0.5, borderRadius: 1, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, }} > navigate(1)} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, p: 0.5, borderRadius: 1, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, }} > OpenSwarm {showUpdateBanner && ( {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`} {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`} {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`} {updateStatus === 'downloading' && ( )} {updateStatus === 'downloading' && ( {Math.round(downloadPercent)}% )} {updateStatus === 'available' && ( )} {updateStatus === 'downloaded' && ( )} )} {!sidebarCollapsed && ( <> {/* Dashboards section */} {dashboardList.length > 0 && ( )} 0} timeout={200}> {dashboardList.map((entry) => { const isActive = activeDashboardId === entry.id; const isRenaming = renamingDashboardId === entry.id; return ( handleDashboardItemClick(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 1.25, pr: 1, py: isRenaming ? 0.25 : 0.5, ml: '-0.5px', cursor: isRenaming ? 'default' : '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', }} > {isRenaming ? ( setRenameValue(e.target.value)} onBlur={() => handleDashboardRenameSubmit(entry.id)} onKeyDown={(e) => { if (e.key === 'Enter') handleDashboardRenameSubmit(entry.id); if (e.key === 'Escape') setRenamingDashboardId(null); }} onClick={(e) => e.stopPropagation()} onFocus={(e) => e.target.select()} sx={{ flex: 1, minWidth: 0, fontSize: '0.78rem', fontWeight: isActive ? 500 : 400, color: isActive ? c.text.secondary : c.text.ghost, py: 0, px: 0.5, borderRadius: 0.75, border: `1px solid ${c.accent.primary}80`, bgcolor: `${c.bg.page}`, '& input': { padding: '1px 0', }, }} /> ) : ( { e.stopPropagation(); handleStartDashboardRename(entry.id, entry.name); }} sx={{ color: isActive ? c.text.secondary : c.text.ghost, fontSize: '0.78rem', fontWeight: isActive ? 500 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0, }} > {entry.name} )} ); })} {/* Divider */} {/* Customization section */} { 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) => ( {({ 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 */} dispatch(openSettingsModal())} sx={{ borderRadius: 1.5, py: 0.6, px: 1.25, '&:hover': { bgcolor: `${c.text.tertiary}0A` }, transition: 'background-color 0.15s', }} > {showUpdateDot && ( )} )} setSnackbarDismissed(true)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > : } action={ {updateStatus === 'available' && ( )} {updateStatus === 'downloaded' && ( )} } sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}`, boxShadow: c.shadow.md, '& .MuiAlert-icon': { color: c.accent.primary }, }} > {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`} {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded — restart to update`} ); }; export default AppShell;