From 6e73399d570c7629cad663cb1a4585b78fef3f29 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 3 Aug 2026 15:05:15 -0700 Subject: [PATCH] [eric] updater: banner and snackbar collapse into one top-right restart pill the drag strip can't eat --- .../src/app/components/Layout/AppShell.tsx | 218 +----------------- .../app/components/Layout/UpdateReadyPill.tsx | 112 +++++++++ 2 files changed, 114 insertions(+), 216 deletions(-) create mode 100644 frontend/src/app/components/Layout/UpdateReadyPill.tsx diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index f089ec5f..c725c85e 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -7,18 +7,9 @@ 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'; @@ -34,7 +25,7 @@ import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addVi import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice'; import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; -import { setInstalling } from '@/shared/state/updateSlice'; +import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; @@ -42,8 +33,6 @@ import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip'; import { washBackgroundUrl, effectiveWashStops } 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(); @@ -63,18 +52,6 @@ const AppShell: React.FC = () => { // (left-edge hover peeks it; the pin toggle brings it back full-time). const [sidebarCollapsed, setSidebarCollapsed] = useState(true); - const updateStatus = useAppSelector((state) => state.update.status); - const availableVersion = useAppSelector((state) => state.update.availableVersion); - const downloadPercent = useAppSelector((state) => state.update.downloadPercent); - const installing = useAppSelector((state) => state.update.installing); - // Windows' Squirrel never reports a version, and a mid-download cache-clear reload wipes it, so render the name version-less instead of "OpenSwarm null". - const verSuffix = availableVersion ? ` ${availableVersion}` : ''; - - const [dismissedVersion, setDismissedVersion] = useState(() => { - try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } - }); - const [snackbarDismissed, setSnackbarDismissed] = useState(false); - const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { @@ -159,30 +136,6 @@ const AppShell: React.FC = () => { // 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 && settingsKnown && ((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, @@ -575,100 +528,7 @@ const AppShell: React.FC = () => { - {showUpdateBanner && !fsHideChrome && ( - - - - {updateStatus === 'available' && `OpenSwarm${verSuffix} is available`} - {updateStatus === 'downloading' && `Downloading OpenSwarm${verSuffix}…`} - {updateStatus === 'downloaded' && `OpenSwarm${verSuffix} is ready to install`} - - {updateStatus === 'downloading' && ( - - )} - {updateStatus === 'downloading' && ( - - {Math.round(downloadPercent)}% - - )} - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - - - - )} + {!fsHideChrome && } {/* Sidebar excised: dashboards live in the Spaces strip (hover the top edge; right-click a tile for rename/duplicate/delete). */} @@ -714,80 +574,6 @@ const AppShell: React.FC = () => { - setSnackbarDismissed(true)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - - : - } - action={ - - - {updateStatus === 'available' && ( - - )} - {updateStatus === 'downloaded' && ( - - )} - - } - sx={{ - bgcolor: c.bg.surface, - color: c.text.primary, - border: `1px solid ${c.border.medium}`, - boxShadow: c.shadow.md, - '& .MuiAlert-icon': { color: c.accent.primary }, - }} - > - {updateStatus === 'available' && `OpenSwarm${verSuffix} is available`} - {updateStatus === 'downloaded' && `OpenSwarm${verSuffix} downloaded; restart to update`} - - - - ); }; diff --git a/frontend/src/app/components/Layout/UpdateReadyPill.tsx b/frontend/src/app/components/Layout/UpdateReadyPill.tsx new file mode 100644 index 00000000..c996d712 --- /dev/null +++ b/frontend/src/app/components/Layout/UpdateReadyPill.tsx @@ -0,0 +1,112 @@ +import React, { useCallback, useState } from 'react'; +import Box from '@mui/material/Box'; +import Grow from '@mui/material/Grow'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import CloseIcon from '@mui/icons-material/Close'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { setInstalling } from '@/shared/state/updateSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed'; + +// Chrome/Claude-style: the download already ran silently (autoDownload in main.js), so the only +// state worth pixels is "ready". One pill, top-right, above the frameless-window drag strip that +// used to swallow the old banner button's clicks. +const UpdateReadyPill: React.FC = () => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const updateStatus = useAppSelector((s) => s.update.status); + const availableVersion = useAppSelector((s) => s.update.availableVersion); + const installing = useAppSelector((s) => s.update.installing); + const [dismissedVersion, setDismissedVersion] = useState(() => { + try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; } + }); + const [sessionDismissed, setSessionDismissed] = useState(false); + const [hovered, setHovered] = useState(false); + + const handleInstall = useCallback(() => { + if (installing) return; + dispatch(setInstalling()); + (window as any).openswarm?.installUpdate(); + }, [installing, dispatch]); + + const handleDismiss = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setSessionDismissed(true); + // Squirrel reports no version, so a persisted dismissal there would hide every FUTURE update too; those stay session-only. + if (availableVersion) { + try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {} + setDismissedVersion(availableVersion); + } + }, [availableVersion]); + + const dismissed = sessionDismissed || (availableVersion !== null && dismissedVersion === availableVersion); + const show = updateStatus === 'downloaded' && !dismissed; + + return ( + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={handleInstall} + role="button" + aria-label={availableVersion ? `Restart to update to ${availableVersion}` : 'Restart to update'} + title={availableVersion ? `OpenSwarm ${availableVersion} downloaded` : 'Update downloaded'} + sx={{ + position: 'fixed', + top: 34, + right: 16, + zIndex: 1400, + WebkitAppRegion: 'no-drag', + display: 'flex', + alignItems: 'center', + gap: 0.75, + height: 30, + pl: 1.25, + pr: 1, + borderRadius: 999, + bgcolor: c.accent.primary, + color: '#fff', + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', + cursor: installing ? 'default' : 'pointer', + userSelect: 'none', + transition: 'background 0.2s ease, transform 0.15s ease', + '&:hover': { bgcolor: installing ? c.accent.primary : c.accent.pressed }, + '&:active': { transform: installing ? 'none' : 'scale(0.97)' }, + }} + > + {installing + ? + : } + + {installing ? 'Restarting…' : 'Restart to update'} + + {!installing && ( + + + + )} + + + ); +}; + +export default UpdateReadyPill;