mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 20:52:23 +02:00
# Conflicts: # backend/apps/agents/manager/run/RunOptions.py # electron/main.js # frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx # frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx # frontend/src/app/pages/Dashboard/cards/NoteCard.tsx
1512 lines
69 KiB
TypeScript
1512 lines
69 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, Search as SearchGlyph, X as CloseGlyph } from 'lucide-react';
|
||
import ButtonBase from '@mui/material/ButtonBase';
|
||
import { AnimatedPanelLeft } from './animatedIcons';
|
||
|
||
const SEARCH_HOTKEY_LABEL = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl+K';
|
||
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, addViewCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice';
|
||
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
|
||
import { fetchOutputs, deleteOutput, updateOutput } from '@/shared/state/outputsSlice';
|
||
import { removeViewCardCleanly } from '@/shared/viewTeardown';
|
||
import { setInstalling } from '@/shared/state/updateSlice';
|
||
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
|
||
import { byPreviewRecency } from '@/shared/previewOrder';
|
||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||
|
||
const SIDEBAR_MIN = 160;
|
||
const SIDEBAR_MAX = 400;
|
||
// 260 matches Claude.ai's nav-sidebar width: roomy enough that names don't truncate.
|
||
const SIDEBAR_DEFAULT = 260;
|
||
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
|
||
const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
|
||
|
||
const 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.
|
||
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);
|
||
// 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 [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
|
||
const [renamingAppId, setRenamingAppId] = useState<string | null>(null);
|
||
const [renameValue, setRenameValue] = useState('');
|
||
// Arc-style delete: the row vanishes at once but the real delete waits behind an Undo toast.
|
||
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
|
||
const pendingDeleteRef = useRef<{ id: string; name: string } | null>(null);
|
||
const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
// Arc-style peek: hovering an app row (with intent, not a fly-by) floats a preview beside the sidebar.
|
||
const [peek, setPeek] = useState<{ name: string; description: string; thumbnail: string | null; top: number } | null>(null);
|
||
const peekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
// Arc-style Space switching: a horizontal two-finger swipe over the sidebar flips dashboards. Kept OFF
|
||
// the canvas (which owns horizontal pan) so the two never fight. accum + cooldown = one flip per swipe.
|
||
const swipeAccumRef = useRef(0);
|
||
const swipeCooldownRef = useRef(false);
|
||
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];
|
||
// 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(() => {
|
||
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/');
|
||
// 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;
|
||
const sidebarAway = (sidebarCollapsed || fsActive) && isDashboardViewActive;
|
||
const [sidePeek, setSidePeek] = useState(false);
|
||
useEffect(() => { if (!sidebarAway) setSidePeek(false); }, [sidebarAway]);
|
||
// 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]);
|
||
// Close-on-leave with a grace delay (cancelled on re-enter): a bare mouseLeave closed the peek the
|
||
// instant the cursor dipped past the panel edge while reaching for an item, so clicks never landed.
|
||
const peekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const cancelPeekClose = useCallback(() => {
|
||
if (peekCloseTimerRef.current) { clearTimeout(peekCloseTimerRef.current); peekCloseTimerRef.current = null; }
|
||
}, []);
|
||
const schedulePeekClose = useCallback(() => {
|
||
cancelPeekClose();
|
||
peekCloseTimerRef.current = setTimeout(() => setSidePeek(false), 260);
|
||
}, [cancelPeekClose]);
|
||
// Reliable window-level close: the panel's own mouseLeave can get eaten (webview/overlay capture),
|
||
// leaving the peek stuck open. This fires on every move and closes ONLY when the cursor is clearly
|
||
// to the right of the floating panel (generous 130px buffer so reaching for an item never closes it).
|
||
useEffect(() => {
|
||
if (!sidePeek) return undefined;
|
||
const onMove = (e: MouseEvent): void => {
|
||
if (e.clientX > 10 + sidebarWidth + 130) schedulePeekClose();
|
||
else cancelPeekClose();
|
||
};
|
||
window.addEventListener('mousemove', onMove);
|
||
return () => window.removeEventListener('mousemove', onMove);
|
||
}, [sidePeek, sidebarWidth, schedulePeekClose, cancelPeekClose]);
|
||
// Fullscreen still hides the top-center island anchor + banners; the sidebar floats in on peek.
|
||
const fsHideChrome = fsActive;
|
||
// In overlay mode the panel stays MOUNTED (so it can slide out, not vanish); sidePeek only drives the slide.
|
||
const sideOverlay = sidebarAway;
|
||
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]);
|
||
|
||
const handleSidebarSwipe = useCallback((e: React.WheelEvent) => {
|
||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) { swipeAccumRef.current = 0; return; } // vertical = list scroll
|
||
if (swipeCooldownRef.current) return;
|
||
swipeAccumRef.current += e.deltaX;
|
||
if (Math.abs(swipeAccumRef.current) >= 80) {
|
||
switchDashboard(swipeAccumRef.current > 0 ? 1 : -1);
|
||
swipeAccumRef.current = 0;
|
||
swipeCooldownRef.current = true;
|
||
setTimeout(() => { swipeCooldownRef.current = false; }, 450);
|
||
}
|
||
}, [switchDashboard]);
|
||
|
||
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.
|
||
const navigateToApp = useCallback((id: string) => {
|
||
dispatch(addViewCard({ outputId: id }));
|
||
if (lastDashboardId && location.pathname !== `/dashboard/${lastDashboardId}`) {
|
||
navigate(`/dashboard/${lastDashboardId}`);
|
||
}
|
||
}, [dispatch, navigate, lastDashboardId, location.pathname]);
|
||
// The real delete, run only once Undo has lapsed: tear the live view card down cleanly (never rip a
|
||
// webview GPU surface mid-composite), then delete the output for good.
|
||
const commitDeleteApp = useCallback((id: string) => {
|
||
void removeViewCardCleanly(id, dispatch);
|
||
dispatch(deleteOutput(id));
|
||
}, [dispatch]);
|
||
|
||
// Arc-style delete: the row hides immediately and an Undo toast holds for 6s. Undo restores it;
|
||
// silence commits it. A second delete flushes the first (only one pending at a time).
|
||
const handleDeleteApp = useCallback((e: React.MouseEvent, id: string, name: string) => {
|
||
e.stopPropagation();
|
||
if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current);
|
||
const prev = pendingDeleteRef.current;
|
||
if (prev && prev.id !== id) commitDeleteApp(prev.id);
|
||
const entry = { id, name };
|
||
pendingDeleteRef.current = entry;
|
||
setPendingDelete(entry);
|
||
deleteTimerRef.current = setTimeout(() => {
|
||
commitDeleteApp(id);
|
||
pendingDeleteRef.current = null;
|
||
setPendingDelete(null);
|
||
deleteTimerRef.current = null;
|
||
}, 6000);
|
||
}, [commitDeleteApp]);
|
||
|
||
const handleUndoDeleteApp = useCallback(() => {
|
||
if (deleteTimerRef.current) { clearTimeout(deleteTimerRef.current); deleteTimerRef.current = null; }
|
||
pendingDeleteRef.current = null;
|
||
setPendingDelete(null);
|
||
}, []);
|
||
|
||
// Leaving the app while a delete is still pending shouldn't strand a half-deleted ghost: commit it.
|
||
useEffect(() => () => {
|
||
if (deleteTimerRef.current) { clearTimeout(deleteTimerRef.current); }
|
||
if (pendingDeleteRef.current) commitDeleteApp(pendingDeleteRef.current.id);
|
||
if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
|
||
}, [commitDeleteApp]);
|
||
|
||
const handleStartAppRename = (id: string, currentName: string) => {
|
||
setRenamingDashboardId(null);
|
||
setRenamingAppId(id);
|
||
setRenameValue(currentName);
|
||
};
|
||
|
||
const handleAppRenameSubmit = (id: string) => {
|
||
const trimmed = renameValue.trim();
|
||
const previousName = outputItems[id]?.name;
|
||
if (trimmed && trimmed !== previousName) {
|
||
dispatch(updateOutput({ id, name: trimmed }));
|
||
}
|
||
setRenamingAppId(null);
|
||
};
|
||
// With the /apps route gone, an app row is "active" when its card is open on the dashboard, not from the URL.
|
||
const openViewCardOutputIds = useAppSelector((s) =>
|
||
new Set(Object.values(s.dashboardLayout.viewCards).map((vc) => vc.output_id)),
|
||
);
|
||
|
||
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) => {
|
||
setRenamingAppId(null);
|
||
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 = () => {
|
||
setAppsExpanded((prev) => !prev);
|
||
};
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary }}>
|
||
{sidebarAway && !sidePeek && (
|
||
<Box onMouseEnter={() => { cancelPeekClose(); setSidePeek(true); }} sx={{ position: 'fixed', top: 0, left: 0, bottom: 0, width: 14, zIndex: 2147483000, pointerEvents: 'auto' }} />
|
||
)}
|
||
{/* 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.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 && !fsHideChrome} 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 && !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.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 && !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.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 && !fsHideChrome) || sideOverlay) && (
|
||
<>
|
||
<Box
|
||
onMouseEnter={() => { if (sideOverlay) cancelPeekClose(); }}
|
||
onMouseLeave={() => { if (sideOverlay) schedulePeekClose(); }}
|
||
onWheel={handleSidebarSwipe}
|
||
sx={{
|
||
width: sidebarWidth,
|
||
flexShrink: 0,
|
||
bgcolor: c.bg.secondary,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
// Zen/Arc compact mode: a detached, rounded panel that SLIDES in and out from the left edge
|
||
// (sidePeek drives the transform both ways; it stays mounted so leaving glides it away, not vanish).
|
||
...(sideOverlay ? {
|
||
// Hug the window's top/left/bottom edges (same footprint as the docked sidebar) so the
|
||
// dashboard's corner tint can't peek in the margins; only the dashboard-facing right corners
|
||
// stay rounded. The OS clips the square outer corners to the window's own rounding, so the
|
||
// window edge (the "last border") stays intact.
|
||
position: 'fixed', top: 0, left: 0, bottom: 0, zIndex: 1000002,
|
||
borderRadius: '0 14px 14px 0', overflow: 'hidden',
|
||
boxShadow: '8px 0 32px rgba(0,0,0,0.28)', borderRight: `1px solid ${c.border.medium}`,
|
||
transform: sidePeek ? 'translateX(0)' : 'translateX(-118%)',
|
||
transition: 'transform 240ms cubic-bezier(0.22,1,0.36,1)',
|
||
pointerEvents: sidePeek ? 'auto' : 'none',
|
||
} : {}),
|
||
}}
|
||
>
|
||
{/* Sidebar header = the app's chrome home (Arc/Zen): window-light clearance, back/forward, collapse, then the search command bar. */}
|
||
<Box sx={{ pt: '30px', px: 1, pb: 0.75, display: 'flex', flexDirection: 'column', gap: 0.75, WebkitAppRegion: 'drag', flexShrink: 0 }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, WebkitAppRegion: 'no-drag' }}>
|
||
<Tooltip title="Back">
|
||
<span>
|
||
<IconButton size="small" onClick={() => navigate(-1)} disabled={!canGoBack}
|
||
sx={{ 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={17} />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
<Tooltip title="Forward">
|
||
<span>
|
||
<IconButton size="small" onClick={() => navigate(1)} disabled={!canGoForward}
|
||
sx={{ 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={17} />
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
<Box sx={{ flex: 1 }} />
|
||
<Tooltip title={sideOverlay ? 'Dock sidebar' : 'Hide sidebar'}>
|
||
{/* Arc-style pin: from the floating peek this DOCKS the sidebar permanently (pushes the
|
||
canvas), from docked it collapses back to peek. Toggle, not one-way collapse. */}
|
||
<IconButton size="small" onClick={() => setSidebarCollapsed((v) => !v)}
|
||
data-onboarding="sidebar-toggle" aria-expanded={!sidebarCollapsed}
|
||
sx={{ color: c.text.tertiary, p: 0.5, borderRadius: 1, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` } }}>
|
||
<AnimatedPanelLeft size={17} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
<ButtonBase
|
||
onClick={() => window.dispatchEvent(new CustomEvent('openswarm:open-search'))}
|
||
sx={{
|
||
WebkitAppRegion: 'no-drag',
|
||
display: 'flex', alignItems: 'center', gap: 1, width: '100%', justifyContent: 'flex-start',
|
||
px: 1.25, py: 0.9, borderRadius: 2, border: `1px solid ${c.border.medium}`, bgcolor: c.bg.surface,
|
||
color: c.text.tertiary, transition: 'border-color 0.15s, background 0.15s',
|
||
'&:hover': { borderColor: c.border.strong, bgcolor: c.bg.page },
|
||
}}
|
||
>
|
||
<SearchGlyph size={15} />
|
||
<Typography sx={{ flex: 1, textAlign: 'left', fontSize: '0.86rem', color: c.text.muted }}>Search</Typography>
|
||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, fontFamily: c.font.mono }}>{SEARCH_HOTKEY_LABEL}</Typography>
|
||
</ButtonBase>
|
||
</Box>
|
||
<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,
|
||
},
|
||
}}
|
||
/>
|
||
{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) => {
|
||
if (pendingDelete?.id === app.id) return null; // hidden while its Undo toast is up
|
||
const isActive = openViewCardOutputIds.has(app.id);
|
||
const isRenamingApp = renamingAppId === app.id;
|
||
const appName = app.name || 'Untitled App';
|
||
return (
|
||
<Box
|
||
key={app.id}
|
||
onClick={() => { if (!isRenamingApp) navigateToApp(app.id); }}
|
||
onMouseEnter={(e) => {
|
||
if (isRenamingApp) return;
|
||
const top = e.currentTarget.getBoundingClientRect().top;
|
||
if (peekTimerRef.current) clearTimeout(peekTimerRef.current);
|
||
peekTimerRef.current = setTimeout(() => setPeek({ name: appName, description: app.description || '', thumbnail: app.thumbnail || null, top }), 350);
|
||
}}
|
||
onMouseLeave={() => {
|
||
if (peekTimerRef.current) { clearTimeout(peekTimerRef.current); peekTimerRef.current = null; }
|
||
setPeek(null);
|
||
}}
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 0.75,
|
||
pl: 1.25,
|
||
pr: 0.5,
|
||
py: isRenamingApp ? 0.25 : 0.5,
|
||
mx: 0.5,
|
||
cursor: isRenamingApp ? 'default' : 'pointer',
|
||
borderRadius: `${c.radius.md}px`,
|
||
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
|
||
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
|
||
'& .app-del-btn': { opacity: 0, transition: 'opacity 0.12s' },
|
||
'&:hover .app-del-btn': { opacity: 1 },
|
||
transition: 'background-color 0.12s',
|
||
}}
|
||
>
|
||
{isRenamingApp ? (
|
||
<InputBase
|
||
autoFocus
|
||
value={renameValue}
|
||
onChange={(e) => setRenameValue(e.target.value)}
|
||
onBlur={() => handleAppRenameSubmit(app.id)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') handleAppRenameSubmit(app.id);
|
||
if (e.key === 'Escape') setRenamingAppId(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' },
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
<Typewriter value={appName} enabled={!!app.name && app.name !== 'Untitled App'}>
|
||
{(t) => (
|
||
<Typography
|
||
onDoubleClick={(e) => { e.stopPropagation(); handleStartAppRename(app.id, appName); }}
|
||
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>
|
||
<IconButton
|
||
className="app-del-btn"
|
||
size="small"
|
||
aria-label={`Delete ${appName}`}
|
||
onClick={(e) => handleDeleteApp(e, app.id, appName)}
|
||
sx={{ p: 0.25, flexShrink: 0, color: c.text.ghost, '&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}18` } }}
|
||
>
|
||
<CloseGlyph size={13} />
|
||
</IconButton>
|
||
</>
|
||
)}
|
||
</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={{
|
||
// Hide the resize seam when the sidebar is a floating overlay (nothing to resize against).
|
||
display: sideOverlay ? 'none' : 'block',
|
||
// 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. 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',
|
||
}}>
|
||
{/* 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>
|
||
|
||
{/* Arc-style peek: a live-thumbnail preview beside the sidebar while hovering an app row. Non-interactive so it never steals the hover. */}
|
||
{peek && !sidebarCollapsed && (
|
||
<Box
|
||
sx={{
|
||
position: 'fixed',
|
||
left: sidebarWidth + 8,
|
||
top: Math.min(Math.max(peek.top - 8, 12), window.innerHeight - 232),
|
||
width: 264,
|
||
zIndex: 1400,
|
||
pointerEvents: 'none',
|
||
borderRadius: `${c.radius.lg}px`,
|
||
overflow: 'hidden',
|
||
bgcolor: c.bg.surface,
|
||
border: `1px solid ${c.border.medium}`,
|
||
boxShadow: c.shadow.lg,
|
||
'@keyframes peekIn': { from: { opacity: 0, transform: 'translateX(-6px)' }, to: { opacity: 1, transform: 'none' } },
|
||
animation: 'peekIn 0.14s ease-out',
|
||
}}
|
||
>
|
||
{peek.thumbnail ? (
|
||
<Box component="img" src={peek.thumbnail} alt="" sx={{ width: '100%', height: 150, objectFit: 'cover', objectPosition: 'top left', display: 'block' }} />
|
||
) : (
|
||
<Box sx={{ width: '100%', height: 92, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.text.tertiary}0A`, color: c.text.ghost, fontSize: '0.78rem' }}>
|
||
No preview yet
|
||
</Box>
|
||
)}
|
||
<Box sx={{ p: 1.25 }}>
|
||
<Typography sx={{ fontWeight: 600, fontSize: '0.9rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{peek.name}</Typography>
|
||
{peek.description && (
|
||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||
{peek.description}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Arc-style Undo for a just-deleted app. Open while the delete is pending; our own 6s timer commits it and closes this. */}
|
||
<Snackbar
|
||
open={!!pendingDelete}
|
||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||
>
|
||
<Alert
|
||
severity="info"
|
||
icon={false}
|
||
action={
|
||
<Button
|
||
size="small"
|
||
onClick={handleUndoDeleteApp}
|
||
sx={{ color: c.accent.primary, textTransform: 'none', fontWeight: 700, fontSize: '0.8rem', minWidth: 'auto' }}
|
||
>
|
||
Undo
|
||
</Button>
|
||
}
|
||
sx={{
|
||
bgcolor: c.bg.surface,
|
||
color: c.text.primary,
|
||
border: `1px solid ${c.border.medium}`,
|
||
boxShadow: c.shadow.md,
|
||
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
|
||
}}
|
||
>
|
||
{`Deleted "${pendingDelete?.name ?? ''}"`}
|
||
</Alert>
|
||
</Snackbar>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default AppShell;
|