Files
openswarm/frontend/src/app/components/Layout/AppShell.tsx
T

1006 lines
37 KiB
TypeScript

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: <DescriptionIcon /> },
{ label: 'Skills', path: '/skills', icon: <PsychologyIcon /> },
{ label: 'Actions', path: '/actions', icon: <BuildIcon /> },
{ label: 'Modes', path: '/modes', icon: <TuneIcon /> },
];
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<string | null>(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<string | null>(() => {
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
{/* Draggable title bar */}
<Box
sx={{
height: 38,
flexShrink: 0,
bgcolor: c.bg.secondary,
borderBottom: `0.5px solid ${c.border.medium}`,
display: 'flex',
alignItems: 'center',
position: 'relative',
overflow: 'visible',
WebkitAppRegion: 'drag',
userSelect: 'none',
pl: '78px',
gap: 0.25,
}}
>
<Tooltip title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
<IconButton
size="small"
onClick={() => 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` },
}}
>
<ViewSidebarOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Back">
<IconButton
size="small"
onClick={() => 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` },
}}
>
<ArrowBackOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Forward">
<IconButton
size="small"
onClick={() => 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` },
}}
>
<ArrowForwardOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<DynamicIsland />
<Box sx={{ flex: 1 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pr: 1.5,
WebkitAppRegion: 'no-drag',
}}
>
<Box
component="img"
src="./logo.png"
alt="OpenSwarm"
sx={{ width: 16, height: 16, borderRadius: 0.5, opacity: 0.6 }}
/>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.72rem',
fontWeight: 500,
letterSpacing: 0.3,
lineHeight: 1,
}}
>
OpenSwarm
</Typography>
</Box>
</Box>
{showUpdateBanner && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 0.5,
bgcolor: `${c.accent.primary}14`,
borderBottom: `1px solid ${c.accent.primary}30`,
flexShrink: 0,
}}
>
<SystemUpdateAltIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`}
</Typography>
{updateStatus === 'downloading' && (
<LinearProgress
variant="determinate"
value={downloadPercent}
sx={{
width: 120,
height: 3,
flexShrink: 0,
borderRadius: 2,
bgcolor: `${c.accent.primary}20`,
'& .MuiLinearProgress-bar': { bgcolor: c.accent.primary, borderRadius: 2 },
}}
/>
)}
{updateStatus === 'downloading' && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, flexShrink: 0 }}>
{Math.round(downloadPercent)}%
</Typography>
)}
{updateStatus === 'available' && (
<Button
size="small"
variant="contained"
onClick={handleDownloadUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.75rem',
fontWeight: 600,
borderRadius: 1.5,
minWidth: 'auto',
py: 0.25,
px: 1.5,
lineHeight: 1.5,
flexShrink: 0,
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
size="small"
variant="contained"
onClick={handleInstallUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.75rem',
fontWeight: 600,
borderRadius: 1.5,
minWidth: 'auto',
py: 0.25,
px: 1.5,
lineHeight: 1.5,
flexShrink: 0,
}}
>
Restart & Update
</Button>
)}
<IconButton
size="small"
onClick={handleDismissBanner}
sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0, '&:hover': { color: c.text.secondary } }}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
{!sidebarCollapsed && (
<>
<Box
sx={{
width: sidebarWidth,
flexShrink: 0,
bgcolor: c.bg.secondary,
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ flex: 1, overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 } }}>
{/* Dashboards section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleDashboardsClick}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.25,
bgcolor: isDashboardRoute ? `${c.accent.primary}12` : 'transparent',
'&:hover': { bgcolor: isDashboardRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
<ListItemIcon sx={{ color: isDashboardRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<DashboardIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText
primary="Dashboards"
sx={{
'& .MuiListItemText-primary': {
color: isDashboardRoute ? c.text.primary : c.text.muted,
fontSize: '0.82rem',
fontWeight: isDashboardRoute ? 600 : 400,
},
}}
/>
<Tooltip title="New dashboard" placement="right">
<IconButton
size="small"
onClick={handleCreateDashboard}
sx={{
color: c.text.ghost,
p: 0.25,
mr: 0.25,
borderRadius: 1,
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
}}
>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
{dashboardList.length > 0 && (
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 16,
transition: 'transform 0.2s',
transform: dashboardsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</ListItemButton>
<Collapse in={dashboardsExpanded && dashboardList.length > 0} timeout={200}>
<Box
sx={{
ml: 2,
mt: 0.25,
mb: 0.5,
borderLeft: `1px solid ${c.border.medium}`,
maxHeight: 240,
overflow: 'auto',
'&::-webkit-scrollbar': { width: 3 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{dashboardList.map((entry) => {
const isActive = activeDashboardId === entry.id;
const isRenaming = renamingDashboardId === entry.id;
return (
<Box
key={entry.id}
onClick={() => 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 ? (
<InputBase
autoFocus
value={renameValue}
onChange={(e) => 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',
},
}}
/>
) : (
<Typography
onDoubleClick={(e) => {
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}
</Typography>
)}
</Box>
);
})}
</Box>
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Customization section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => {
if (isCustomizationRoute) {
setCustomizationExpanded((prev) => !prev);
} else {
navigate('/customization');
setCustomizationExpanded(true);
}
}}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.25,
bgcolor: isCustomizationRoute ? `${c.accent.primary}12` : 'transparent',
'&:hover': { bgcolor: isCustomizationRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
<ListItemIcon sx={{ color: isCustomizationRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<ExtensionIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText
primary="Customization"
sx={{
'& .MuiListItemText-primary': {
color: isCustomizationRoute ? c.text.primary : c.text.muted,
fontSize: '0.82rem',
fontWeight: isCustomizationRoute ? 600 : 400,
},
}}
/>
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 16,
transition: 'transform 0.2s',
transform: customizationExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</ListItemButton>
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
{CUSTOMIZATION_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{({ isActive }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.25,
pr: 1,
py: 0.5,
ml: '-0.5px',
cursor: 'pointer',
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.12s, border-color 0.12s',
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.78rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{item.label}
</Typography>
</Box>
)}
</NavLink>
))}
</Box>
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Apps section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleAppsClick}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.25,
bgcolor: isAppsRoute ? `${c.accent.primary}12` : 'transparent',
'&:hover': { bgcolor: isAppsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
<ListItemIcon sx={{ color: isAppsRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
<ViewQuiltIcon sx={{ fontSize: 20 }} />
</ListItemIcon>
<ListItemText
primary="Apps"
sx={{
'& .MuiListItemText-primary': {
color: isAppsRoute ? c.text.primary : c.text.muted,
fontSize: '0.82rem',
fontWeight: isAppsRoute ? 600 : 400,
},
}}
/>
<Tooltip title="New app" placement="right">
<IconButton
size="small"
onClick={handleCreateApp}
sx={{
color: c.text.ghost,
p: 0.25,
mr: 0.25,
borderRadius: 1,
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
}}
>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
{appsList.length > 0 && (
<ExpandMoreIcon
sx={{
color: c.text.ghost,
fontSize: 16,
transition: 'transform 0.2s',
transform: appsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</ListItemButton>
<Collapse in={appsExpanded && appsList.length > 0} timeout={200}>
<Box
sx={{
ml: 2,
mt: 0.25,
mb: 0.5,
borderLeft: `1px solid ${c.border.medium}`,
maxHeight: 240,
overflow: 'auto',
'&::-webkit-scrollbar': { width: 3 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
}}
>
{appsList.map((app) => {
const isActive = activeAppId === app.id;
return (
<Box
key={app.id}
onClick={() => navigate(`/apps/${app.id}`)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
pl: 1.25,
pr: 1,
py: 0.5,
ml: '-0.5px',
cursor: 'pointer',
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.12s, border-color 0.12s',
}}
>
<Typography
sx={{
color: isActive ? c.text.secondary : c.text.ghost,
fontSize: '0.78rem',
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{app.name}
</Typography>
</Box>
);
})}
</Box>
</Collapse>
</Box>
</Box>
{/* Settings */}
<Box
sx={{
px: 1,
py: 1,
borderTop: `0.5px solid ${c.border.subtle}`,
}}
>
<ListItemButton
onClick={() => dispatch(openSettingsModal())}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.25,
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
transition: 'background-color 0.15s',
}}
>
<ListItemIcon sx={{ color: c.text.tertiary, minWidth: 32, position: 'relative' }}>
<SettingsIcon sx={{ fontSize: 20 }} />
{showUpdateDot && (
<Box
sx={{
position: 'absolute',
top: 2,
right: 10,
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: c.accent.primary,
border: `1.5px solid ${c.bg.secondary}`,
}}
/>
)}
</ListItemIcon>
<ListItemText
primary="Settings"
sx={{
'& .MuiListItemText-primary': {
color: c.text.muted,
fontSize: '0.82rem',
fontWeight: 400,
},
}}
/>
</ListItemButton>
</Box>
</Box>
<Box
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
sx={{
width: 6,
flexShrink: 0,
cursor: 'col-resize',
position: 'relative',
zIndex: 10,
'&::after': {
content: '""',
position: 'absolute',
top: 0,
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
width: 2,
bgcolor: 'transparent',
transition: 'background-color 0.2s',
},
'&:hover::after': {
bgcolor: c.border.strong,
},
'&:active::after': {
bgcolor: `${c.accent.primary}40`,
},
}}
/>
</>
)}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page }}>
<Outlet />
</Box>
</Box>
<Settings />
<Snackbar
open={showUpdateSnackbar}
autoHideDuration={10000}
onClose={() => setSnackbarDismissed(true)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
icon={updateStatus === 'downloaded'
? <RestartAltIcon sx={{ fontSize: 18 }} />
: <SystemUpdateAltIcon sx={{ fontSize: 18 }} />
}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
size="small"
onClick={() => setSnackbarDismissed(true)}
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto' }}
>
Dismiss
</Button>
{updateStatus === 'available' && (
<Button
size="small"
variant="contained"
onClick={handleDownloadUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
borderRadius: 1.5,
minWidth: 'auto',
}}
>
Download
</Button>
)}
{updateStatus === 'downloaded' && (
<Button
size="small"
variant="contained"
onClick={handleInstallUpdate}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.8rem',
borderRadius: 1.5,
minWidth: 'auto',
}}
>
Restart & Update
</Button>
)}
</Box>
}
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`}
</Alert>
</Snackbar>
</Box>
);
};
export default AppShell;