import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } 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'; // One outlined icon language for the sidebar: thin monoline glyphs (not the // filled Material clip-art) so the rail reads as designed, not assembled. import { LayoutDashboard } from 'lucide-react'; import PsychologyIcon from '@mui/icons-material/PsychologyOutlined'; import BuildIcon from '@mui/icons-material/BuildOutlined'; import TuneIcon from '@mui/icons-material/TuneOutlined'; import { LayoutGrid } from 'lucide-react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { Settings as LucideSettings } from 'lucide-react'; import { Palette } from 'lucide-react'; import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'; import { AnimatedPanelLeft } from './animatedIcons'; 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 CircularProgress from '@mui/material/CircularProgress'; // Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint. const Settings = React.lazy(() => import('@/app/pages/Settings/Settings')); import DynamicIsland from '@/app/components/overlays/DynamicIsland'; import Dashboard from '@/app/pages/Dashboard/Dashboard'; import DashboardHost from '@/app/components/Layout/DashboardHost'; import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { shallowEqual } from 'react-redux'; import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice'; import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; import { setInstalling } from '@/shared/state/updateSlice'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; const SIDEBAR_MIN = 160; const SIDEBAR_MAX = 400; // 260 matches Claude.ai's nav-sidebar width: roomy enough that names don't truncate. const SIDEBAR_DEFAULT = 260; const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width'; const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; const CUSTOMIZATION_ITEMS = [ { label: 'Skills', path: '/skills', icon: , onboarding: 'sidebar-skills' }, { label: 'Actions', path: '/actions', icon: , onboarding: 'sidebar-actions' }, { label: 'Modes', path: '/modes', icon: , onboarding: 'sidebar-modes' }, ]; const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path)); const AppShell: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const navigateRaw = useNavigate(); // startTransition wrapper: route swap becomes non-urgent so click handler returns immediately; eliminates the "click, wait, page appears" gap on slow routes. const navigate = useMemo(() => { const fn = (...args: Parameters) => { startTransition(() => { (navigateRaw as any)(...args); }); }; return fn as typeof navigateRaw; }, [navigateRaw]); // Navigate to an app instantly on click. The old debounce here swallowed clicks the // user could see (felt broken) and never actually fixed the crash, since letting each // app load defeats the debounce anyway. The real GPU-churn source (the WebGL loading // placeholder) is now CSS, and the 250ms preview gate still skips webviews for apps // switched-past too fast, so instant navigation is safe. const navigateToApp = useCallback((id: string) => { navigate(`/apps/${id}`); }, [navigate]); const location = useLocation(); // React Router (HashRouter) stores a monotonic index in history state. location // re-renders on every nav, by which point window.history.state.idx is updated. const historyIdx = (window.history.state?.idx as number | undefined) ?? 0; const maxHistoryIdx = useRef(0); maxHistoryIdx.current = Math.max(maxHistoryIdx.current, historyIdx); const canGoBack = historyIdx > 0; const canGoForward = historyIdx < maxHistoryIdx.current; const [dashboardsExpanded, setDashboardsExpanded] = useState(true); const [appsExpanded, setAppsExpanded] = useState(true); // Collapsed by default: config rows are progressive disclosure, not daily nav. Onboarding reads data-expanded and clicks to open when it needs them. const [customizationExpanded, setCustomizationExpanded] = useState(false); // Starts collapsed so a fresh boot lands on a clean canvas; the toggle brings it back. const [sidebarCollapsed, setSidebarCollapsed] = useState(true); 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 installing = useAppSelector((state) => state.update.installing); // Windows' Squirrel never reports a version, and a mid-download cache-clear reload wipes it, so render the name version-less instead of "OpenSwarm null". const verSuffix = availableVersion ? ` ${availableVersion}` : ''; const [dismissedVersion, setDismissedVersion] = useState(() => { try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } }); const [snackbarDismissed, setSnackbarDismissed] = useState(false); const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { const goOnline = () => setIsOnline(true); const goOffline = () => setIsOnline(false); window.addEventListener('online', goOnline); window.addEventListener('offline', goOffline); return () => { window.removeEventListener('online', goOnline); window.removeEventListener('offline', goOffline); }; }, []); // /agents/models intersects BUILTIN_MODELS with API keys + 9Router state; non-empty means at least one usable model. const modelsByProvider = useAppSelector((s) => s.models.byProvider); const modelsLoaded = useAppSelector((s) => s.models.loaded); const hasModelConnected = Object.keys(modelsByProvider).length > 0; // Wait for initial fetch to land before flashing the banner. const showWarningBanner = !isOnline || (modelsLoaded && !hasModelConnected); 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(() => { if (installing) return; dispatch(setInstalling()); (window as any).openswarm?.installUpdate(); }, [installing, dispatch]); // shallowEqual on top-level Immer dicts: nested mutations bump the dict reference, causing AppShell to re-render on every rename/output bump despite identical structure. const dashboardItems = useAppSelector( (state) => state.dashboards.items, shallowEqual, ); const dashboardList = React.useMemo( () => Object.values(dashboardItems).sort(byPreviewRecency), [dashboardItems], ); const outputItems = useAppSelector( (state) => state.outputs.items, shallowEqual, ); const appsList = React.useMemo( () => Object.values(outputItems).sort(byPreviewRecency), [outputItems], ); useEffect(() => { dispatch(fetchDashboards()); dispatch(fetchOutputs()); }, [dispatch]); // Idle-prefetch the lazy Settings chunk so click-to-open is instant; requestIdleCallback avoids fighting first-paint. useEffect(() => { const ric = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500)); const handle = ric(() => { import('@/app/pages/Settings/Settings').catch(() => {}); }, { timeout: 3000 }); return () => { const cic = (window as any).cancelIdleCallback || clearTimeout; try { cic(handle); } catch {} }; }, []); 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]); useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail || {}; const { sessionId, dashboardId } = detail as { sessionId?: string; dashboardId?: string }; if (!sessionId) return; if (dashboardId) { navigate(`/dashboard/${dashboardId}`); } dispatch(setPendingFocusAgentId(sessionId)); }; window.addEventListener('openswarm:notification-click', handler as EventListener); return () => window.removeEventListener('openswarm:notification-click', handler as EventListener); }, [navigate, dispatch]); 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 isDashboardViewActive = 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 [lastDashboardId, setLastDashboardId] = useLastDashboardId(); 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(); const previousName = dashboardItems[id]?.name; if (trimmed && trimmed !== previousName) { dispatch(renameDashboard({ id, name: trimmed, previousName })); } 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 ( setSidebarCollapsed((prev) => !prev)} // Onboarding runtime reads aria-expanded to detect a collapsed sidebar. data-onboarding="sidebar-toggle" aria-expanded={!sidebarCollapsed} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, p: 0.5, borderRadius: 1, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, }} > {/* span wrapper so a disabled button still shows its Tooltip; lucide glyph + hover-slide kept from the redesign, disabled-state from #68. */} navigate(-1)} disabled={!canGoBack} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, p: 0.5, borderRadius: 1, '& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' }, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, '&:hover svg': { transform: 'translateX(-2px)' }, }} > navigate(1)} disabled={!canGoForward} sx={{ WebkitAppRegion: 'no-drag', color: c.text.tertiary, p: 0.5, borderRadius: 1, '& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' }, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` }, '&:hover svg': { transform: 'translateX(2px)' }, }} > OpenSwarm {!isOnline ? 'No internet connection; agents cannot reach AI models or external services' : ( <> No AI model connected.{' '} dispatch(openSettingsModal('models'))} sx={{ textDecoration: 'underline', cursor: 'pointer', fontWeight: 600, '&:hover': { opacity: 0.8 }, transition: 'opacity 0.15s', }} > Configure models {' '}to get started )} {showUpdateBanner && ( {updateStatus === 'available' && `OpenSwarm${verSuffix} is available`} {updateStatus === 'downloading' && `Downloading OpenSwarm${verSuffix}…`} {updateStatus === 'downloaded' && `OpenSwarm${verSuffix} is ready to install`} {updateStatus === 'downloading' && ( )} {updateStatus === 'downloading' && ( {Math.round(downloadPercent)}% )} {updateStatus === 'available' && ( )} {updateStatus === 'downloaded' && ( )} )} {!sidebarCollapsed && ( <> {dashboardList.length > 0 && ( )} 0} timeout={200}> {dashboardList.map((entry, idx) => { 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, mx: 0.5, cursor: isRenaming ? 'default' : 'pointer', // Finder-style selection: the rounded fill is the one active cue, no rail marker. borderRadius: `${c.radius.md}px`, bgcolor: isActive ? `${c.accent.primary}40` : 'transparent', '&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` }, transition: 'background-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.86rem', 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.86rem', fontWeight: isActive ? 500 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0, }} > {entry.name} )} ); })} {/* Sections separate with air, not lines. */} { if (isCustomizationRoute) { setCustomizationExpanded((prev) => !prev); } else { navigate('/customization'); setCustomizationExpanded(true); } }} data-onboarding="sidebar-customization" data-expanded={customizationExpanded ? 'true' : 'false'} aria-expanded={customizationExpanded} 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) => { // Manual click handler instead of NavLink: NavLink's internal navigate bypasses our startTransition wrapper. const isActive = location.pathname === item.path; return ( navigate(item.path)} onMouseEnter={() => { // Hover-prefetch lazy chunk so click is ~0ms (see Main.tsx for path -> import map). const fn = (window as any).__openswarmPrefetchRoute; if (typeof fn === 'function') fn(item.path); }} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 1.25, pr: 1, py: 0.5, mx: 0.5, cursor: 'pointer', // 25% accent alpha needed for readable contrast on dark-mode bg.secondary; 10% muddied to grey. borderRadius: `${c.radius.md}px`, bgcolor: isActive ? `${c.accent.primary}40` : 'transparent', '&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` }, transition: 'background-color 0.12s', }} > {item.label} ); })} {/* Sections separate with air, not lines. */} { const fn = (window as any).__openswarmPrefetchRoute; if (typeof fn === 'function') fn('/apps'); }} data-onboarding="sidebar-apps" sx={{ borderRadius: 1.5, py: 0.6, px: 1.25, bgcolor: isAppsRoute ? `${c.accent.primary}12` : 'transparent', '&:hover': { bgcolor: isAppsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` }, transition: 'background-color 0.15s', }} > {appsList.length > 0 && ( )} 0} timeout={200}> {appsList.map((app) => { const isActive = activeAppId === app.id; return ( navigateToApp(app.id)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 1.25, pr: 1, py: 0.5, mx: 0.5, cursor: 'pointer', borderRadius: `${c.radius.md}px`, bgcolor: isActive ? `${c.accent.primary}40` : 'transparent', '&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` }, transition: 'background-color 0.12s', }} > {app.name} ); })} dispatch(openSettingsModal())} data-onboarding="sidebar-settings-button" sx={{ borderRadius: 1.5, py: 0.6, px: 1.25, '& .MuiListItemIcon-root svg': { transition: 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)' }, '&:hover': { bgcolor: `${c.text.tertiary}0A` }, '&:hover .MuiListItemIcon-root svg': { transform: 'rotate(90deg)' }, transition: 'background-color 0.15s', }} > {showUpdateDot && ( )} )} {/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */} {/* CSS-hidden on other routes so webviews + state survive nav. */} {lastDashboardId && ( )} 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${verSuffix} is available`} {updateStatus === 'downloaded' && `OpenSwarm${verSuffix} downloaded; restart to update`} ); }; export default AppShell;