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 ## 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 ## Settings discovery

View File

@@ -375,17 +375,23 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
min-width: max-content; 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 { .settings-modal .settings-update-check {
align-items: center; align-items: center;
flex-wrap: nowrap; flex-wrap: wrap;
row-gap: 0; row-gap: var(--space-xs);
} }
.settings-modal .settings-update-result { .settings-modal .settings-update-result {
align-self: center; align-self: center;
flex: 0 1 auto; flex: 0 1 auto;
line-height: 1; line-height: normal;
white-space: nowrap; white-space: normal;
} }
.settings-modal .settings-footer-help-btn { .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; 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 { .settings-update-install-status {
color: var(--text-muted); 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 { 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 { import {
getErrorMessage, getErrorMessage,
resolveGitlabConfig, resolveGitlabConfig,
@@ -8,7 +8,7 @@ import {
} from "@fusion/core"; } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { DEFAULT_GLOBAL_SETTINGS } 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 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 { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
import { import {
@@ -1307,6 +1307,10 @@ export function SettingsModal({
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null); const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
const [updateInstallLoading, setUpdateInstallLoading] = useState(false); const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null); 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 gitHubStarCount = useGitHubStarCount();
const [starClicked, markStarClicked] = useStarClickedFlag(); const [starClicked, markStarClicked] = useStarClickedFlag();
const [prefixError, setPrefixError] = useState<string | null>(null); const [prefixError, setPrefixError] = useState<string | null>(null);
@@ -1830,6 +1834,10 @@ export function SettingsModal({
const handleCheckForUpdates = useCallback(async () => { const handleCheckForUpdates = useCallback(async () => {
setUpdateCheckLoading(true); setUpdateCheckLoading(true);
setUpdateInstallResult(null); setUpdateInstallResult(null);
setRestartSupported(undefined);
setRestartLoading(false);
setRestartScheduled(false);
setRestartError(null);
try { try {
const result = await checkForUpdates(); const result = await checkForUpdates();
@@ -1855,6 +1863,9 @@ export function SettingsModal({
const handleInstallUpdate = useCallback(async () => { const handleInstallUpdate = useCallback(async () => {
setUpdateInstallLoading(true); setUpdateInstallLoading(true);
setUpdateInstallResult(null); setUpdateInstallResult(null);
setRestartLoading(false);
setRestartScheduled(false);
setRestartError(null);
try { try {
const result = await installUpdate(projectId); const result = await installUpdate(projectId);
@@ -1882,6 +1893,53 @@ export function SettingsModal({
} }
}, [addToast, appVersion, projectId, t, updateCheckResult]); }, [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(() => { const renderUpdateCheckResultContent = useCallback(() => {
if (!updateCheckResult) { if (!updateCheckResult) {
return null; return null;
@@ -1909,10 +1967,48 @@ export function SettingsModal({
</a> </a>
</span> </span>
{installSucceeded ? ( {installSucceeded ? (
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite"> <span className="settings-update-install-succeeded">
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { <span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion, {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> </span>
) : ( ) : (
<button <button
@@ -1943,7 +2039,7 @@ export function SettingsModal({
} }
return t("settings.general.upToDate", "You're up to date ✓"); 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 // Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => { 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 { render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react";
import path from "path"; import path from "path";
import { SettingsModal } from "../SettingsModal"; import { SettingsModal } from "../SettingsModal";
@@ -42,6 +42,8 @@ import {
mockFetchDashboardHealth, mockFetchDashboardHealth,
mockCheckForUpdates, mockCheckForUpdates,
mockInstallUpdate, mockInstallUpdate,
mockFetchSystemInfo,
mockRequestSystemRestart,
mockFetchRemoteSettings, mockFetchRemoteSettings,
mockUpdateRemoteSettings, mockUpdateRemoteSettings,
mockFetchRemoteStatus, mockFetchRemoteStatus,
@@ -117,6 +119,8 @@ vi.mock("../../api", async (importOriginal) => {
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args), fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args), checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args), installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args), fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args), updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args), fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
@@ -151,11 +155,13 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }), useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
})); }));
let viewportMode: "mobile" | "desktop" = "mobile";
vi.mock("../../hooks/useViewportMode", () => ({ vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => "mobile", getViewportMode: () => viewportMode,
isMobileViewport: () => true, isMobileViewport: () => viewportMode === "mobile",
useViewportMode: () => "mobile", useViewportMode: () => viewportMode,
})); }));
vi.mock("lucide-react", async (importOriginal) => { vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>(); const actual = await importOriginal<typeof import("lucide-react")>();
@@ -198,6 +204,131 @@ vi.mock("../FileBrowser", () => ({
describe("SettingsModal", () => { describe("SettingsModal", () => {
installSettingsModalEnv(); 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 = { const deepwikiServer = {
name: "deepwiki", name: "deepwiki",
transport: "stdio" as const, transport: "stdio" as const,

View File

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

View File

@@ -114,6 +114,8 @@ vi.mock("../../api", () => ({
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })), 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 })), 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 })), 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 })), fetchGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
// SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount. // SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount.
fetchWorkflows: vi.fn(() => Promise.resolve([])), 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", "flex: 0 0 auto;");
expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "min-width: max-content;"); 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", "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", "line-height: 1;");
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;"); expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;");
expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;"); expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");

View File

@@ -5909,6 +5909,10 @@
"updateNow": "Update now", "updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply", "updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updateSuccessToast": "Update installed. Restart Fusion to apply it.", "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…", "updating": "Updating…",
"upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.", "upperBoundOnMessagesFetchedFromTheRoom": "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
"upToDate": "You're up to date ✓", "upToDate": "You're up to date ✓",