import React, { useState, useEffect, useCallback, startTransition, useMemo } from 'react'; import { 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 { VoiceDictationProvider } from '@/shared/voice/VoiceDictationContext'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; 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 { Clock } from 'lucide-react'; 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 } from '@/shared/state/dashboardsSlice'; import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard, selectFullscreenCardId, setTiledCard, clearTiledCard } 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, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip'; import { washBackgroundUrl } from '@/shared/styles/washBackground'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; 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) => { startTransition(() => { (navigateRaw as any)(...args); }); }; return fn as typeof navigateRaw; }, [navigateRaw]); 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. // Desktop shell: the wallpaper canvas IS the home surface, so the sidebar starts docked away // (left-edge hover peeks it; the pin toggle brings it back full-time). const [sidebarCollapsed, setSidebarCollapsed] = useState(true); 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); }; }, []); 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); // While onboarding owns the window, the floating sidebar (and its hover-peek strip) must not exist; both out-z the overlay. const v3FlowActive = useAppSelector((st) => st.onboardingV3.flowActive); // Arc/Zen fullscreen ground: ONE themed wash across the whole window (sidebar sits on it borderless, // the content floats as a rounded card). Mirrors the DashboardCanvas wash formula. const { accent: themeAccent, gradient: themeGradient } = useThemeAccent(); const { washOpacity: themeWashOpacity } = useThemeWash(); const fsWashStops = themeGradient ?? (themeAccent ? [themeAccent] : null); // 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(() => { 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], ); 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]; // Only navigate to lastId if it's a REAL dashboard: a stale localStorage id for a deleted dashboard used to route to /dashboard/, which 404s and re-fires the layout wipe (drops your cards / breaks a drag). const lastIsReal = !!lastId && dashboardList.some((d) => d.id === lastId); const targetId = (lastIsReal ? lastId : undefined) || 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(() => { 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 isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/'); const isDashboardViewActive = location.pathname.startsWith('/dashboard/'); // macOS full screen: a fullscreen-tiled card owns the window, so every shell chrome piece hides. Gated on the dashboard view so navigating away restores the chrome even mid-fullscreen. const fullscreenCardId = useAppSelector(selectFullscreenCardId); // Zen compact mode: the sidebar is the only chrome now, so whenever it's "away" (user collapsed it, // OR a fullscreen card hides everything) a left-edge hover floats it back in as an overlay. const fsActive = !!fullscreenCardId && isDashboardViewActive; // Arc: the sidebar toggle PINS the sidebar open inside fullscreen (docked, card shrinks beside it); // unpinned fullscreen keeps the hover-peek overlay. const [fsSidebarPinned, setFsSidebarPinned] = useState(false); const sidebarAway = (sidebarCollapsed || (fsActive && !fsSidebarPinned)) && isDashboardViewActive; // When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating // dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads // (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance. useEffect(() => { const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform); const root = document.documentElement; if (sidebarAway && isMac) root.style.setProperty('--osw-header-inset', '80px'); else root.style.removeProperty('--osw-header-inset'); return () => { root.style.removeProperty('--osw-header-inset'); }; }, [sidebarAway]); // Global text-size ratio (Settings > Interface). Scaling the root font-size scales every rem-based // size in one shot, so type grows or shrinks together with no layout breakage. Clamped to a sane band // so a corrupt value can never wreck the whole UI. const uiFontScale = useAppSelector((s) => s.settings.data.ui_font_scale ?? 1); useEffect(() => { const clamped = Math.min(1.4, Math.max(0.8, uiFontScale || 1)); document.documentElement.style.fontSize = `${Math.round(clamped * 100)}%`; }, [uiFontScale]); // When the sidebar is docked, the macOS traffic lights sit over its top strip, which is a window // drag region that swallows mousemove, so the canvas hover-reveal can never fire there. Broadcast // "chrome docked" so the canvas keeps the native lights visible while the sidebar is open (they only // hide-until-hover in the immersive collapsed/fullscreen state). detail.docked = sidebar is present. useEffect(() => { window.dispatchEvent(new CustomEvent('openswarm:chrome-docked', { detail: { docked: !sidebarAway } })); }, [sidebarAway]); // Fullscreen still hides the top-center island anchor + banners; the sidebar floats in on peek. const fsHideChrome = fsActive; const isAppsRoute = false; // /apps route removed; app cards live on the dashboard now. const activeDashboardId = location.pathname.startsWith('/dashboard/') ? location.pathname.split('/dashboard/')[1] : null; // Flip to the previous/next dashboard, clamped at the ends (no surprise wrap). Shared by the sidebar // swipe and the Cmd/Ctrl+Alt+arrow keyboard path. const switchDashboard = useCallback((dir: -1 | 1) => { if (dashboardList.length < 2) return; const idx = dashboardList.findIndex((d) => d.id === activeDashboardId); if (idx < 0) return; const next = Math.min(dashboardList.length - 1, Math.max(0, idx + dir)); if (next !== idx) navigate(`/dashboard/${dashboardList[next].id}`); }, [dashboardList, activeDashboardId, navigate]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || !e.altKey || e.shiftKey) return; if (e.key === 'ArrowLeft') { e.preventDefault(); switchDashboard(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); switchDashboard(1); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [switchDashboard]); const [lastDashboardId, setLastDashboardId] = useLastDashboardId(); // Apps no longer have a full-page editor; clicking one in the sidebar drops (or focuses) its live card on the current dashboard. Fold-in of the old App Builder. While a card is fullscreen the click SWAPS the pinned card to this app (Arc: the sidebar switches what fills the screen), otherwise the new card would land invisibly behind it. const navigateToApp = useCallback((id: string) => { dispatch(addViewCard({ outputId: id })); if (fullscreenCardId) { if (fullscreenCardId !== id) dispatch(clearTiledCard(fullscreenCardId)); dispatch(setTiledCard({ cardId: id, zone: 'fullscreen' })); return; } if (lastDashboardId && location.pathname !== `/dashboard/${lastDashboardId}`) { navigate(`/dashboard/${lastDashboardId}`); } }, [dispatch, navigate, lastDashboardId, location.pathname, fullscreenCardId]); return ( {/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the spaces hot zone keeps the frameless window draggable (the sidebar's drag strip is gone). */} {isDashboardViewActive && !v3FlowActive && } {/* Top bar dropped (Arc/Zen): a zero-height anchor left only to float the agent-activity island at top-center; the island renders nothing when idle. */} {!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 )} {freeTrialSpent ? (refillLabel ? `Out of free runs, fresh ones in ${refillLabel}. ` : "You're out of free runs for now. ") : "Nice, you're rolling. "} dispatch(openSettingsModal('models'))} sx={{ color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }} > Connect the Claude or ChatGPT you already have {freeTrialSpent ? '.' : ' to keep going unlimited.'} {!freeTrialSpent && ( { try { localStorage.setItem('os_ft_nudge_dismissed', '1'); } catch {} setFtNudgeDismissed(true); }} sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '1rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }} > × )} {/* the bar is the message: how full your Pro window is. calm accent, never red. */} {usageResetLabel && ( {usageResetLabel} )} {proMaxed && ( dispatch(openSettingsModal('models'))} sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8125rem', '&:hover': { textDecoration: 'underline' } }} > Upgrade )} {showUpdateBanner && !fsHideChrome && ( {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' && ( )} )} {/* Sidebar excised: dashboards live in the Spaces strip (hover the top edge; right-click a tile for rename/duplicate/delete). */} {/* One voice controller wraps BOTH the routed content and the persistent Dashboard host, so the spawn-pill mic (which lives in the persistent host, not the Outlet) shares the recorder. */} {/* 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;