diff --git a/.changeset/fn-8138-settings-restart-after-update.md b/.changeset/fn-8138-settings-restart-after-update.md new file mode 100644 index 0000000000..a46926a685 --- /dev/null +++ b/.changeset/fn-8138-settings-restart-after-update.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a one-click "Restart Fusion" button to the Settings modal after an in-app update. +category: feature +dev: Adds a capability-aware restart affordance to SettingsModal's footer update-success state; reuses POST /api/system/restart via requestSystemRestart and restartSupported, with a disabled manual-restart fallback when unsupervised. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f2b950f66c..6b0a90775e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -6,7 +6,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett ## Dashboard Updates -When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, the dashboard update banner offers a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), that banner action is disabled and explains that Fusion must be restarted manually. +When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, both the Settings update-success state and dashboard update banner offer a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), either action remains disabled and explains that Fusion must be restarted manually. ## Settings discovery diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index eb97ea54c1..0ab0231a13 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -375,17 +375,23 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi min-width: max-content; } + /* + FNXC:SettingsUpdate 2026-07-16-00:00: + FN-8138 adds a restart control and manual-restart explanation after an update. + Let this inner result flow wrap on narrow screens while the existing footer rail + remains horizontally scrollable, so the new controls never create a clipped shell. + */ .settings-modal .settings-update-check { align-items: center; - flex-wrap: nowrap; - row-gap: 0; + flex-wrap: wrap; + row-gap: var(--space-xs); } .settings-modal .settings-update-result { align-self: center; flex: 0 1 auto; - line-height: 1; - white-space: nowrap; + line-height: normal; + white-space: normal; } .settings-modal .settings-footer-help-btn { @@ -517,6 +523,13 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi animation: settings-update-spin 1s linear infinite; } +.settings-update-install-succeeded { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); +} + .settings-update-install-status { color: var(--text-muted); } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 119e152caa..98d4151ae9 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react"; -import { Globe, Folder, GitBranch, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react"; +import { Globe, Folder, GitBranch, Power, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react"; import { getErrorMessage, resolveGitlabConfig, @@ -8,7 +8,7 @@ import { } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core"; -import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; +import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api"; import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split"; import { @@ -1307,6 +1307,10 @@ export function SettingsModal({ const [updateCheckResult, setUpdateCheckResult] = useState(null); const [updateInstallLoading, setUpdateInstallLoading] = useState(false); const [updateInstallResult, setUpdateInstallResult] = useState(null); + const [restartSupported, setRestartSupported] = useState(); + const [restartLoading, setRestartLoading] = useState(false); + const [restartScheduled, setRestartScheduled] = useState(false); + const [restartError, setRestartError] = useState(null); const gitHubStarCount = useGitHubStarCount(); const [starClicked, markStarClicked] = useStarClickedFlag(); const [prefixError, setPrefixError] = useState(null); @@ -1830,6 +1834,10 @@ export function SettingsModal({ const handleCheckForUpdates = useCallback(async () => { setUpdateCheckLoading(true); setUpdateInstallResult(null); + setRestartSupported(undefined); + setRestartLoading(false); + setRestartScheduled(false); + setRestartError(null); try { const result = await checkForUpdates(); @@ -1855,6 +1863,9 @@ export function SettingsModal({ const handleInstallUpdate = useCallback(async () => { setUpdateInstallLoading(true); setUpdateInstallResult(null); + setRestartLoading(false); + setRestartScheduled(false); + setRestartError(null); try { const result = await installUpdate(projectId); @@ -1882,6 +1893,53 @@ export function SettingsModal({ } }, [addToast, appVersion, projectId, t, updateCheckResult]); + useEffect(() => { + if (!updateCheckResult?.updateAvailable && updateInstallResult?.updated !== true) { + return; + } + + let cancelled = false; + setRestartSupported(undefined); + + void fetchSystemInfo() + .then((info) => { + if (!cancelled) setRestartSupported(info.restartSupported); + }) + .catch(() => { + // Fail closed: system capability fetch errors must not expose an unavailable restart action. + if (!cancelled) setRestartSupported(false); + }); + + return () => { + cancelled = true; + }; + }, [updateCheckResult?.updateAvailable, updateInstallResult?.updated]); + + /* + FNXC:SettingsUpdate 2026-07-16-00:00: + After a successful in-app update, the Settings footer must offer the same supervised + one-click restart as SystemControlsArea. The FN-8134-deferred Settings surface keeps + the control disabled with manual-restart guidance unless restartSupported is true. + */ + const handleRestart = useCallback(async () => { + if (restartLoading || restartSupported !== true) return; + + setRestartLoading(true); + setRestartError(null); + try { + const result = await requestSystemRestart("settings-update"); + if (result.scheduled) { + setRestartScheduled(true); + } else { + setRestartError(t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually.")); + } + } catch (error) { + setRestartError(getErrorMessage(error) || t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually.")); + } finally { + setRestartLoading(false); + } + }, [restartLoading, restartSupported, t]); + const renderUpdateCheckResultContent = useCallback(() => { if (!updateCheckResult) { return null; @@ -1909,10 +1967,48 @@ export function SettingsModal({ {installSucceeded ? ( - - {t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { - version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion, - })} + + + {t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { + version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion, + })} + + {restartScheduled ? ( + + {t("settings.general.restarting", "Restarting… Your connection will close shortly.")} + + ) : ( + + )} + {restartSupported !== true && ( + + {t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")} + + )} + {restartError && ( + + {restartError} + + )} ) : (