mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 23:28:45 +02:00
774 lines
38 KiB
TypeScript
774 lines
38 KiB
TypeScript
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<typeof navigateRaw>) => {
|
||
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<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);
|
||
// 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<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],
|
||
);
|
||
|
||
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/<phantom>, 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 (
|
||
<Box sx={{
|
||
display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary,
|
||
...(fsActive && fsWashStops ? { backgroundImage: washBackgroundUrl(fsWashStops, themeWashOpacity), backgroundSize: '100% 100%' } : {}),
|
||
}}>
|
||
{/* 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 && <SpacesStrip />}
|
||
<Box sx={{ position: 'fixed', top: 3, left: 260, right: 0, height: 22, zIndex: 5, WebkitAppRegion: 'drag' }} />
|
||
{/* 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. */}
|
||
<Box
|
||
sx={{
|
||
height: 0,
|
||
flexShrink: 0,
|
||
position: 'relative',
|
||
overflow: 'visible',
|
||
zIndex: 10,
|
||
display: fsHideChrome ? 'none' : 'block',
|
||
}}
|
||
>
|
||
<DynamicIsland />
|
||
</Box>
|
||
|
||
<Collapse in={showWarningBanner && !fsHideChrome} 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.875rem', 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 && !fsHideChrome} timeout={300} unmountOnExit>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
|
||
<Typography sx={{ fontSize: '0.8125rem', 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: '1rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }}
|
||
>
|
||
×
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
|
||
<Collapse in={showUsageNudge && !fsHideChrome} 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.8125rem', letterSpacing: '0.01em' }}>{usageResetLabel}</Typography>
|
||
</Box>
|
||
)}
|
||
{proMaxed && (
|
||
<Box
|
||
component="span"
|
||
onClick={() => dispatch(openSettingsModal('models'))}
|
||
sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8125rem', '&:hover': { textDecoration: 'underline' } }}
|
||
>
|
||
Upgrade
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
|
||
{showUpdateBanner && !fsHideChrome && (
|
||
<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.8125rem', 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.75rem', 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 }}>
|
||
{/* Sidebar excised: dashboards live in the Spaces strip (hover the top edge; right-click a tile for rename/duplicate/delete). */}
|
||
|
||
<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. Fullscreen drops the frame entirely.
|
||
mt: fsHideChrome ? 0 : '6px',
|
||
mr: fsHideChrome ? 0 : '6px',
|
||
mb: fsHideChrome ? 0 : '6px',
|
||
ml: fsHideChrome ? 0 : '6px',
|
||
borderRadius: fsHideChrome ? 0 : '14px',
|
||
}}>
|
||
{/* 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. */}
|
||
<VoiceDictationProvider>
|
||
{/* 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>
|
||
)}
|
||
</VoiceDictationProvider>
|
||
</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.8125rem', 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.8125rem',
|
||
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.8125rem',
|
||
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;
|