FN-8138: add restart controls after Settings updates

Expose a supervised restart affordance after an in-app update succeeds.

- Detect restart capability while update results are visible and handle scheduled or failed restart requests
- Add accessible restart status, capability guidance, responsive styling, and English translations
- Cover restart states across desktop and mobile Settings modal tests

Files changed:
 .../fn-8138-settings-restart-after-update.md       |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/SettingsModal.css     |  21 +++-
 .../dashboard/app/components/SettingsModal.tsx     | 110 ++++++++++++++--
 .../__tests__/SettingsModal.general.test.tsx       | 139 ++++++++++++++++++++-
 .../__tests__/SettingsModal.test-harness.tsx       |   4 +
 .../components/__tests__/settings-mobile.test.tsx  |   4 +-
 packages/i18n/locales/en/app.json                  |   4 +
 8 files changed, 274 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-8138

Fusion-Task-Lineage: 24e8cb9a-ad64-4419-b3ce-461850624365

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 14:40:26 -07:00
parent d870878a23
commit e7c5de0a6e
8 changed files with 274 additions and 17 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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<UpdateCheckResponse | null>(null);
const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null);
const [restartSupported, setRestartSupported] = useState<boolean | undefined>();
const [restartLoading, setRestartLoading] = useState(false);
const [restartScheduled, setRestartScheduled] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const gitHubStarCount = useGitHubStarCount();
const [starClicked, markStarClicked] = useStarClickedFlag();
const [prefixError, setPrefixError] = useState<string | null>(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({
</a>
</span>
{installSucceeded ? (
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
})}
<span className="settings-update-install-succeeded">
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
})}
</span>
{restartScheduled ? (
<span className="settings-update-install-status" aria-live="polite">
{t("settings.general.restarting", "Restarting… Your connection will close shortly.")}
</span>
) : (
<button
type="button"
className="btn btn-sm settings-update-now-btn"
onClick={() => {
void handleRestart();
}}
disabled={restartSupported !== true || restartLoading}
>
{restartLoading ? (
<>
<RefreshCw size={12} className="spinning" aria-hidden="true" />
{t("settings.general.restarting", "Restarting…")}
</>
) : (
<>
<Power size={12} aria-hidden="true" />
{t("settings.general.restartNow", "Restart Fusion")}
</>
)}
</button>
)}
{restartSupported !== true && (
<span className="settings-update-install-status" aria-live="polite">
{t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")}
</span>
)}
{restartError && (
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
{restartError}
</span>
)}
</span>
) : (
<button
@@ -1943,7 +2039,7 @@ export function SettingsModal({
}
return t("settings.general.upToDate", "You're up to date ✓");
}, [handleInstallUpdate, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
}, [handleInstallUpdate, handleRestart, restartError, restartLoading, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, it, expect, vi } from "vitest";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react";
import path from "path";
import { SettingsModal } from "../SettingsModal";
@@ -42,6 +42,8 @@ import {
mockFetchDashboardHealth,
mockCheckForUpdates,
mockInstallUpdate,
mockFetchSystemInfo,
mockRequestSystemRestart,
mockFetchRemoteSettings,
mockUpdateRemoteSettings,
mockFetchRemoteStatus,
@@ -117,6 +119,8 @@ vi.mock("../../api", async (importOriginal) => {
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
@@ -151,11 +155,13 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
}));
let viewportMode: "mobile" | "desktop" = "mobile";
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => "mobile",
isMobileViewport: () => true,
useViewportMode: () => "mobile",
getViewportMode: () => viewportMode,
isMobileViewport: () => viewportMode === "mobile",
useViewportMode: () => viewportMode,
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
@@ -198,6 +204,131 @@ vi.mock("../FileBrowser", () => ({
describe("SettingsModal", () => {
installSettingsModalEnv();
afterEach(() => {
viewportMode = "mobile";
});
const availableUpdate = {
currentVersion: "1.2.3",
latestVersion: "2.0.0",
updateAvailable: true,
};
async function renderUpdatedSettings() {
mockCheckForUpdates.mockResolvedValue(availableUpdate);
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" }));
await screen.findByRole("button", { name: "Update now" });
await settingsModalUser.click(screen.getByRole("button", { name: "Update now" }));
return screen.findByRole("button", { name: "Restart Fusion" });
}
describe("update restart affordance", () => {
it("renders an enabled restart button after a successful update on desktop", async () => {
viewportMode = "desktop";
const restartButton = await renderUpdatedSettings();
expect(restartButton).toBeEnabled();
expect(restartButton).toHaveAccessibleName("Restart Fusion");
});
it("renders a wrapping, enabled restart control after a successful update on mobile", async () => {
const restartButton = await renderUpdatedSettings();
expect(restartButton).toBeEnabled();
expect(restartButton).toHaveAccessibleName("Restart Fusion");
expect(restartButton.closest(".settings-update-install-succeeded")).toBeInTheDocument();
expect(settingsModalCss).toMatch(/\.settings-modal \.settings-update-check\s*\{[^}]*flex-wrap: wrap;/s);
});
it("requests the supervised restart with the Settings update reason", async () => {
const restartButton = await renderUpdatedSettings();
await settingsModalUser.click(restartButton);
expect(mockRequestSystemRestart).toHaveBeenCalledTimes(1);
expect(mockRequestSystemRestart).toHaveBeenCalledWith("settings-update");
expect(await screen.findByText(/Restarting… Your connection will close shortly/)).toBeInTheDocument();
});
it("keeps the restart button disabled with manual guidance when unsupported", async () => {
mockFetchSystemInfo.mockResolvedValue({ supervised: false, restartSupported: false });
const restartButton = await renderUpdatedSettings();
expect(restartButton).toBeDisabled();
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
});
it("keeps the restart button disabled while system information is loading", async () => {
mockFetchSystemInfo.mockReturnValue(new Promise(() => {}));
const restartButton = await renderUpdatedSettings();
expect(restartButton).toBeDisabled();
});
it("fails closed with manual guidance when system information cannot load", async () => {
mockFetchSystemInfo.mockRejectedValue(new Error("unavailable"));
const restartButton = await renderUpdatedSettings();
await waitFor(() => expect(restartButton).toBeDisabled());
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
});
it("disables the restart button and shows a spinner while scheduling", async () => {
mockRequestSystemRestart.mockReturnValue(new Promise(() => {}));
const restartButton = await renderUpdatedSettings();
await settingsModalUser.click(restartButton);
expect(restartButton).toBeDisabled();
expect(within(restartButton).getByTestId("icon-refresh")).toHaveClass("spinning");
});
it("shows an inline error and allows retry when restart scheduling rejects", async () => {
mockRequestSystemRestart.mockRejectedValue(new Error("Restart unavailable"));
const restartButton = await renderUpdatedSettings();
await settingsModalUser.click(restartButton);
expect(await screen.findByText("Restart unavailable")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("shows an inline error and allows retry when restart scheduling returns false", async () => {
mockRequestSystemRestart.mockResolvedValue({ scheduled: false });
const restartButton = await renderUpdatedSettings();
await settingsModalUser.click(restartButton);
expect(await screen.findByText(/Restart could not be scheduled/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("keeps the retry path and hides restart when update installation fails", async () => {
mockInstallUpdate.mockResolvedValue({
currentVersion: "1.2.3",
latestVersion: "2.0.0",
updated: false,
error: "Install failed",
});
mockCheckForUpdates.mockResolvedValue(availableUpdate);
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" }));
await screen.findByRole("button", { name: "Update now" });
await settingsModalUser.click(screen.getByRole("button", { name: "Update now" }));
expect(await screen.findByText(/Update failed: Install failed/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update now" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Restart Fusion" })).not.toBeInTheDocument();
});
});
const deepwikiServer = {
name: "deepwiki",
transport: "stdio" as const,

View File

@@ -63,6 +63,8 @@ export const mockFetchProjects = vi.fn();
export const mockFetchDashboardHealth = vi.fn();
export const mockCheckForUpdates = vi.fn();
export const mockInstallUpdate = vi.fn();
export const mockFetchSystemInfo = vi.fn();
export const mockRequestSystemRestart = vi.fn();
export const mockFetchRemoteSettings = vi.fn();
export const mockUpdateRemoteSettings = vi.fn();
export const mockFetchRemoteStatus = vi.fn();
@@ -368,6 +370,8 @@ export function installSettingsModalEnv() {
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockCheckForUpdates.mockResolvedValue(undefined);
mockInstallUpdate.mockResolvedValue({ currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true });
mockFetchSystemInfo.mockResolvedValue({ supervised: true, restartSupported: true });
mockRequestSystemRestart.mockResolvedValue({ scheduled: true });
mockFetchRemoteSettings.mockResolvedValue({
settings: {
remoteActiveProvider: null,

View File

@@ -114,6 +114,8 @@ vi.mock("../../api", () => ({
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })),
checkForUpdates: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })),
installUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true })),
fetchSystemInfo: vi.fn(() => Promise.resolve({ supervised: true, restartSupported: true })),
requestSystemRestart: vi.fn(() => Promise.resolve({ scheduled: true })),
fetchGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
// SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount.
fetchWorkflows: vi.fn(() => Promise.resolve([])),
@@ -788,7 +790,7 @@ describe("SettingsModal mobile adaptations", () => {
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "flex: 0 0 auto;");
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "min-width: max-content;");
expectMobileRule(css, ".settings-modal .settings-update-check", "align-items: center;");
expectMobileRule(css, ".settings-modal .settings-update-check", "flex-wrap: nowrap;");
expectMobileRule(css, ".settings-modal .settings-update-check", "flex-wrap: wrap;");
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "line-height: 1;");
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;");
expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");

View File

@@ -5909,6 +5909,10 @@
"updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
"restartFailed": "Restart could not be scheduled. Try restarting Fusion manually.",
"restartNow": "Restart Fusion",
"restartUnavailable": "Needs a supervising parent — restart Fusion manually without --no-supervise.",
"restarting": "Restarting… Your connection will close shortly.",
"updating": "Updating…",
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
"upToDate": "You're up to date ✓",