mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
1294 lines
54 KiB
TypeScript
1294 lines
54 KiB
TypeScript
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 { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus';
|
||
import { getWebview } from '@/shared/browserRegistry';
|
||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||
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 { LayoutGrid } from 'lucide-react';
|
||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||
import { Settings as LucideSettings } from 'lucide-react';
|
||
import { ArrowLeft, ArrowRight, Plus, Clock } 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 { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||
import { shallowEqual } from 'react-redux';
|
||
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
|
||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed } 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 { font } from '@/shared/styles/typography';
|
||
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 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<typeof navigateRaw>) => {
|
||
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);
|
||
// 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<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 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<string | null>(() => {
|
||
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);
|
||
};
|
||
}, []);
|
||
|
||
const modelsLoaded = useAppSelector((s) => s.models.loaded);
|
||
// "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner.
|
||
const hasModelConnected = useAppSelector(selectHasModelConnected);
|
||
// During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it.
|
||
const freeTrialActive = useAppSelector((s) => {
|
||
const d = s.settings.data as any;
|
||
return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token);
|
||
});
|
||
// Trial just ran dry (had an allotment, now 0, off the free lane): a quiet connect nudge, not the red error wall. Runs refill, so it's "for now".
|
||
const freeTrialSpent = useAppSelector((s) => {
|
||
const d = s.settings.data as any;
|
||
return !!(d && (d.free_trial_runs_limit ?? 0) > 0 && d.free_trial_remaining === 0 && d.connection_mode !== 'free-trial');
|
||
});
|
||
// Post-wow: on the free lane and already got value (spent >= 1 run); offer the unlimited path they likely already own while they're happy, not when they're blocked.
|
||
const freeTrialUsed = useAppSelector((s) => {
|
||
const d = s.settings.data as any;
|
||
if (!d || d.connection_mode !== 'free-trial' || !d.free_trial_token) return false;
|
||
const limit = d.free_trial_runs_limit ?? 0;
|
||
const remaining = d.free_trial_remaining ?? limit;
|
||
return limit > 0 && (limit - remaining) >= 1;
|
||
});
|
||
const freeTrialResetsAt = useAppSelector((s) => (s.settings.data as any)?.free_trial_resets_at ?? null);
|
||
// Coarse "~3h" / "~20m" label for when the rolling window refills; null when unknown or basically now. Static (not a ticking countdown) on purpose: a per-second timer is needless churn for a 5h window.
|
||
const refillLabel = React.useMemo(() => {
|
||
if (!freeTrialResetsAt) return null;
|
||
const secs = freeTrialResetsAt - Date.now() / 1000;
|
||
if (secs <= 90) return null;
|
||
const h = Math.floor(secs / 3600);
|
||
if (h >= 1) return `~${h}h`;
|
||
return `~${Math.max(1, Math.round(secs / 60))}m`;
|
||
}, [freeTrialResetsAt]);
|
||
|
||
// Paid (openswarm-pro) usage meter: same calm "you're near/at the cap, here's when it's back" pattern as the free-trial nudge, but the bar IS the message. Only fires in pro mode on real server-owned usage (requests_in_window/plan_limit), and only once near the cap, so it never clutters the normal flow. window_ends_at is unix MS (the trial's resets_at is seconds).
|
||
const proUsage = useAppSelector((s) => {
|
||
const d = s.settings.data as any;
|
||
if (!d || d.connection_mode !== 'openswarm-pro') return null;
|
||
const u = d.openswarm_usage_cached;
|
||
return u && u.plan_limit > 0 ? u : null;
|
||
}, shallowEqual);
|
||
const proPct = proUsage ? Math.min(1, proUsage.requests_in_window / proUsage.plan_limit) : 0;
|
||
const proMaxed = !!proUsage && proPct >= 1;
|
||
const showUsageNudge = isOnline && !!proUsage && proPct >= 0.8;
|
||
const usageResetLabel = React.useMemo(() => {
|
||
const endsAt = proUsage?.window_ends_at ?? 0;
|
||
if (!endsAt) return null;
|
||
const secs = (endsAt - Date.now()) / 1000;
|
||
if (secs <= 90) return null;
|
||
const h = Math.floor(secs / 3600);
|
||
return h >= 1 ? `~${h}h` : `~${Math.max(1, Math.round(secs / 60))}m`;
|
||
}, [proUsage]);
|
||
// Hold the banner until the boot free-trial mint settles, else a brand-new user sees it flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.)
|
||
const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled);
|
||
// The red wall is for genuine "no way to run" only; the free-trial states get the quiet nudge below.
|
||
const showWarningBanner = !isOnline || (modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive && !freeTrialSpent);
|
||
const [ftNudgeDismissed, setFtNudgeDismissed] = useState<boolean>(() => {
|
||
try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; }
|
||
});
|
||
// Spent nudge hides the moment they connect a real model; the post-wow nudge only shows on the trial lane (so it already implies no own model) and is dismissible.
|
||
const showFreeTrialNudge = isOnline && ((freeTrialSpent && !hasModelConnected) || (freeTrialUsed && !ftNudgeDismissed));
|
||
|
||
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, background?: boolean) => {
|
||
const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/);
|
||
if (dashMatch) {
|
||
if (webContentsId != null) {
|
||
const browserId = findBrowserByWebContentsId(webContentsId);
|
||
if (browserId) {
|
||
// Middle-click / background-tab disposition: add the tab but don't steal focus from the current one, like a real browser.
|
||
dispatch(addBrowserTab({ browserId, url, makeActive: !background }));
|
||
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, disposition?: string) => {
|
||
const now = Date.now();
|
||
if (url === lastUrl && now - lastTime < 1000) return;
|
||
lastUrl = url;
|
||
lastTime = now;
|
||
openUrlInBrowser(url, webContentsId, disposition === 'background-tab');
|
||
});
|
||
}, [openUrlInBrowser]);
|
||
|
||
// Track the browser card the user last touched. Chrome clicks land on this document; a webview PAGE click can't reach it, so BrowserCard reports those via the app-clicked IPC. Clearing on any non-browser-card click is what makes Ctrl+R fall back to reloading the app.
|
||
useEffect(() => {
|
||
const onPointerDown = (e: PointerEvent) => {
|
||
const card = (e.target as HTMLElement | null)?.closest?.('[data-select-type="browser-card"]') as HTMLElement | null;
|
||
if (card) setLastInteractedBrowser(card.getAttribute('data-select-id') || '');
|
||
else clearLastInteractedBrowser();
|
||
};
|
||
document.addEventListener('pointerdown', onPointerDown, true);
|
||
return () => document.removeEventListener('pointerdown', onPointerDown, true);
|
||
}, []);
|
||
|
||
// Cmd/Ctrl+R: main neutralizes the default-menu reload and hands us the decision. Reload the browser you're in or last used IN PLACE (keeps its login); only when no browser is open at all fall back to a full app reload, since reloading the renderer destroys every webview and wipes its session. To deliberately reload OpenSwarm itself, use View > Reload.
|
||
useEffect(() => {
|
||
const w = window as any;
|
||
if (!w.openswarm?.onReloadShortcut) return;
|
||
return w.openswarm.onReloadShortcut(() => {
|
||
for (const id of [getLastInteractedBrowser(), ...getKeepAliveBrowserIds()]) {
|
||
const wv = id ? getWebview(id) : undefined;
|
||
if (wv) { try { wv.reload(); return; } catch (_e) { /* torn-down webview; try the next */ } }
|
||
}
|
||
window.location.reload();
|
||
});
|
||
}, []);
|
||
|
||
// Zoom / find / tab-cycle from a focused browser GUEST (keydowns inside a webview can't reach this document, so main forwards them with the guest's id). Targets that exact browser; the host-focused counterparts live in the keydown below + useCanvasControls (zoom).
|
||
useEffect(() => {
|
||
const w = window as any;
|
||
if (!w.openswarm?.onBrowserShortcut) return;
|
||
return w.openswarm.onBrowserShortcut((payload: { action: string; webContentsId: number }) => {
|
||
// Reopen-last-closed is global (no target browser), so handle it before the per-browser id guard.
|
||
if (payload.action === 'reopen-closed') { dispatch(reopenLastClosed()); return; }
|
||
const id = findBrowserByWebContentsId(payload.webContentsId) ?? getLastInteractedBrowser();
|
||
if (!id) return;
|
||
switch (payload.action) {
|
||
case 'zoom-in': applyBrowserZoom(id, 1); break;
|
||
case 'zoom-out': applyBrowserZoom(id, -1); break;
|
||
case 'zoom-reset': applyBrowserZoom(id, 0); break;
|
||
case 'find': window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } })); break;
|
||
case 'tab-next': dispatch(cycleBrowserTab({ browserId: id, dir: 1 })); break;
|
||
case 'tab-prev': dispatch(cycleBrowserTab({ browserId: id, dir: -1 })); break;
|
||
}
|
||
});
|
||
}, [dispatch]);
|
||
|
||
// Host-focused Ctrl/Cmd+F (find) and Ctrl+Tab (cycle) when a browser is the last thing you touched. Zoom keys aren't here: they share the +/-/0 keys with canvas zoom, so useCanvasControls owns that branch.
|
||
useEffect(() => {
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
const id = getLastInteractedBrowser();
|
||
// Require a LIVE webview: a stale id (its card was closed) means no browser is focused, so let the canvas shortcuts (e.g. card-search Cmd+F) handle the key instead.
|
||
if (!id || !getWebview(id)) return;
|
||
const t = e.target as HTMLElement | null;
|
||
const typing = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable;
|
||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && (e.key || '').toLowerCase() === 'f' && !typing) {
|
||
e.preventDefault();
|
||
window.dispatchEvent(new CustomEvent('openswarm:browser-find', { detail: { browserId: id } }));
|
||
} else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'Tab') {
|
||
e.preventDefault();
|
||
dispatch(cycleBrowserTab({ browserId: id, dir: e.shiftKey ? -1 : 1 }));
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
return () => window.removeEventListener('keydown', onKeyDown);
|
||
}, [dispatch]);
|
||
|
||
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 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 (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary }}>
|
||
<Box
|
||
sx={{
|
||
height: 38,
|
||
flexShrink: 0,
|
||
bgcolor: 'transparent',
|
||
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)}
|
||
// 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` },
|
||
}}
|
||
>
|
||
<AnimatedPanelLeft size={18} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Tooltip title="Back">
|
||
{/* span wrapper so a disabled button still shows its Tooltip; lucide
|
||
glyph + hover-slide kept from the redesign, disabled-state from #68. */}
|
||
<span>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => 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)' },
|
||
}}
|
||
>
|
||
<ArrowLeft size={18} />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
<Tooltip title="Forward">
|
||
<span>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => 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)' },
|
||
}}
|
||
>
|
||
<ArrowRight size={18} />
|
||
</IconButton>
|
||
</span>
|
||
</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: 20, height: 20, borderRadius: 0.5, opacity: 0.85 }}
|
||
/>
|
||
<Typography
|
||
sx={{
|
||
color: c.text.secondary,
|
||
fontFamily: font.detail,
|
||
fontSize: '0.9rem',
|
||
fontWeight: 600,
|
||
letterSpacing: 0,
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
OpenSwarm
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Collapse in={showWarningBanner} timeout={350} unmountOnExit>
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1.5,
|
||
px: 2,
|
||
py: 0.6,
|
||
bgcolor: 'rgba(239, 68, 68, 0.08)',
|
||
borderBottom: '1px solid rgba(239, 68, 68, 0.18)',
|
||
flexShrink: 0,
|
||
animation: showWarningBanner ? 'warning-fade-in 0.4s ease-out' : undefined,
|
||
'@keyframes warning-fade-in': {
|
||
from: { opacity: 0 },
|
||
to: { opacity: 1 },
|
||
},
|
||
}}
|
||
>
|
||
<ErrorSlime size={22} />
|
||
<Typography sx={{ fontSize: '0.86rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
|
||
{!isOnline
|
||
? 'No internet connection; agents cannot reach AI models or external services'
|
||
: (
|
||
<>
|
||
No AI model connected.{' '}
|
||
<Box
|
||
component="span"
|
||
onClick={() => dispatch(openSettingsModal('models'))}
|
||
sx={{
|
||
textDecoration: 'underline',
|
||
cursor: 'pointer',
|
||
fontWeight: 600,
|
||
'&:hover': { opacity: 0.8 },
|
||
transition: 'opacity 0.15s',
|
||
}}
|
||
>
|
||
Configure models
|
||
</Box>
|
||
{' '}to get started
|
||
</>
|
||
)}
|
||
</Typography>
|
||
</Box>
|
||
</Collapse>
|
||
|
||
<Collapse in={showFreeTrialNudge} timeout={300} unmountOnExit>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
|
||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, flex: 1, letterSpacing: '0.01em' }}>
|
||
{freeTrialSpent
|
||
? (refillLabel ? `Out of free runs, fresh ones in ${refillLabel}. ` : "You're out of free runs for now. ")
|
||
: "Nice, you're rolling. "}
|
||
<Box
|
||
component="span"
|
||
onClick={() => dispatch(openSettingsModal('models'))}
|
||
sx={{ color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||
>
|
||
Connect the Claude or ChatGPT you already have
|
||
</Box>
|
||
{freeTrialSpent ? '.' : ' to keep going unlimited.'}
|
||
</Typography>
|
||
{!freeTrialSpent && (
|
||
<Box
|
||
role="button"
|
||
aria-label="Dismiss"
|
||
onClick={() => { try { localStorage.setItem('os_ft_nudge_dismissed', '1'); } catch {} setFtNudgeDismissed(true); }}
|
||
sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '0.95rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }}
|
||
>
|
||
×
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
|
||
<Collapse in={showUsageNudge} timeout={300} unmountOnExit>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
|
||
{/* the bar is the message: how full your Pro window is. calm accent, never red. */}
|
||
<Box sx={{ width: 132, height: 5, borderRadius: 3, bgcolor: c.border.medium, overflow: 'hidden', flexShrink: 0 }}>
|
||
<Box sx={{ width: `${Math.round(proPct * 100)}%`, height: '100%', bgcolor: c.accent.primary, transition: 'width 0.3s ease' }} />
|
||
</Box>
|
||
{usageResetLabel && (
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: c.text.secondary }}>
|
||
<Clock size={12} style={{ flexShrink: 0 }} />
|
||
<Typography sx={{ fontSize: '0.8rem', letterSpacing: '0.01em' }}>{usageResetLabel}</Typography>
|
||
</Box>
|
||
)}
|
||
{proMaxed && (
|
||
<Box
|
||
component="span"
|
||
onClick={() => dispatch(openSettingsModal('models'))}
|
||
sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8rem', '&:hover': { textDecoration: 'underline' } }}
|
||
>
|
||
Upgrade
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
|
||
{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${verSuffix} is available`}
|
||
{updateStatus === 'downloading' && `Downloading OpenSwarm${verSuffix}…`}
|
||
{updateStatus === 'downloaded' && `OpenSwarm${verSuffix} 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}
|
||
disabled={installing}
|
||
startIcon={installing ? <CircularProgress size={12} sx={{ color: '#fff' }} /> : undefined}
|
||
sx={{
|
||
bgcolor: c.accent.primary,
|
||
'&:hover': { bgcolor: c.accent.pressed },
|
||
'&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.7 },
|
||
textTransform: 'none',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
borderRadius: 1.5,
|
||
minWidth: 'auto',
|
||
py: 0.25,
|
||
px: 1.5,
|
||
lineHeight: 1.5,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{installing ? 'Restarting…' : '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 },
|
||
// Tactile hover: the leading section icon springs once on row-hover, then settles. Interaction-only, never ambient. Scoped to ListItemIcon so the +/chevron stay put.
|
||
'& .MuiListItemIcon-root svg': {
|
||
transition: 'transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
},
|
||
// Per-glyph hover choreography: each section icon reacts in its own way, springy then settles. Interaction-only, never ambient.
|
||
'& [data-onboarding="sidebar-dashboards"]:hover .MuiListItemIcon-root svg': {
|
||
transform: 'scale(1.14)',
|
||
},
|
||
'& [data-onboarding="sidebar-apps"]:hover .MuiListItemIcon-root svg': {
|
||
transform: 'rotate(8deg) scale(1.08)',
|
||
},
|
||
}}>
|
||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||
<ListItemButton
|
||
onClick={handleDashboardsClick}
|
||
data-onboarding="sidebar-dashboards"
|
||
// Onboarding reads expanded so it skips the click step (re-click would collapse).
|
||
data-expanded={dashboardsExpanded ? 'true' : 'false'}
|
||
aria-expanded={dashboardsExpanded}
|
||
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: 28 }}>
|
||
<LayoutDashboard size={18} />
|
||
</ListItemIcon>
|
||
<ListItemText
|
||
primary="Dashboards"
|
||
sx={{
|
||
'& .MuiListItemText-primary': {
|
||
color: isDashboardRoute ? c.text.primary : c.text.muted,
|
||
fontSize: '0.9rem',
|
||
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` },
|
||
}}
|
||
>
|
||
<Plus size={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,
|
||
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, idx) => {
|
||
const isActive = activeDashboardId === entry.id;
|
||
const isRenaming = renamingDashboardId === entry.id;
|
||
return (
|
||
<Box
|
||
key={entry.id}
|
||
// First row gets generic "first" alias so onboarding can teach "click into a dashboard" without a specific id.
|
||
data-onboarding={
|
||
idx === 0 ? 'dashboard-row-first' : `dashboard-row-${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,
|
||
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 ? (
|
||
<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.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',
|
||
},
|
||
}}
|
||
/>
|
||
) : (
|
||
<Typography
|
||
onDoubleClick={(e) => {
|
||
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}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
|
||
{/* Sections separate with air, not lines. */}
|
||
<Box sx={{ my: 0.75 }} />
|
||
|
||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||
<ListItemButton
|
||
onClick={handleAppsClick}
|
||
onMouseEnter={() => {
|
||
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',
|
||
}}
|
||
>
|
||
<ListItemIcon sx={{ color: isAppsRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
|
||
<LayoutGrid size={18} />
|
||
</ListItemIcon>
|
||
<ListItemText
|
||
primary="Apps"
|
||
sx={{
|
||
'& .MuiListItemText-primary': {
|
||
color: isAppsRoute ? c.text.primary : c.text.muted,
|
||
fontSize: '0.9rem',
|
||
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` },
|
||
}}
|
||
>
|
||
<Plus size={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,
|
||
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={() => 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',
|
||
}}
|
||
>
|
||
<Typewriter value={app.name || 'Untitled App'} enabled={!!app.name && app.name !== 'Untitled App'}>
|
||
{(t) => (
|
||
<Typography
|
||
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,
|
||
}}
|
||
>
|
||
{t}
|
||
</Typography>
|
||
)}
|
||
</Typewriter>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
|
||
</Box>
|
||
|
||
<Box
|
||
sx={{
|
||
px: 1,
|
||
py: 1.25,
|
||
}}
|
||
>
|
||
<ListItemButton
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<ListItemIcon sx={{ color: c.text.tertiary, minWidth: 28, position: 'relative' }}>
|
||
<LucideSettings size={18} />
|
||
{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.9rem',
|
||
fontWeight: 400,
|
||
},
|
||
}}
|
||
/>
|
||
</ListItemButton>
|
||
</Box>
|
||
</Box>
|
||
<Box
|
||
onMouseDown={handleResizeStart}
|
||
onDoubleClick={handleResizeDoubleClick}
|
||
sx={{
|
||
// 6px hit-target at -3px margin overlaps the seam so the drag region doesn't read as a visible empty strip.
|
||
width: 6,
|
||
marginLeft: '-3px',
|
||
marginRight: '-3px',
|
||
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,
|
||
position: 'relative',
|
||
// Float the content as a rounded inset panel ("column pill"): the chrome (bg.secondary) frames it, so there are no divider lines, just air + radius.
|
||
mt: '6px',
|
||
mr: '6px',
|
||
mb: '6px',
|
||
ml: '6px',
|
||
borderRadius: '14px',
|
||
}}>
|
||
{/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
visibility: isDashboardViewActive ? 'hidden' : 'visible',
|
||
pointerEvents: isDashboardViewActive ? 'none' : 'auto',
|
||
}}
|
||
>
|
||
<Outlet />
|
||
</Box>
|
||
|
||
{/* CSS-hidden on other routes so webviews + state survive nav. */}
|
||
{lastDashboardId && (
|
||
<DashboardHost visible={isDashboardViewActive}>
|
||
<Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
|
||
</DashboardHost>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
|
||
<React.Suspense fallback={null}>
|
||
<Settings />
|
||
</React.Suspense>
|
||
|
||
<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}
|
||
disabled={installing}
|
||
startIcon={installing ? <CircularProgress size={12} sx={{ color: '#fff' }} /> : undefined}
|
||
sx={{
|
||
bgcolor: c.accent.primary,
|
||
'&:hover': { bgcolor: c.accent.pressed },
|
||
'&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.7 },
|
||
textTransform: 'none',
|
||
fontSize: '0.8rem',
|
||
borderRadius: 1.5,
|
||
minWidth: 'auto',
|
||
}}
|
||
>
|
||
{installing ? 'Restarting…' : '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${verSuffix} is available`}
|
||
{updateStatus === 'downloaded' && `OpenSwarm${verSuffix} downloaded; restart to update`}
|
||
</Alert>
|
||
</Snackbar>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default AppShell;
|