FN-7506: add scoped settings reset actions

Add a Settings footer reset flow for scoped menu and project-wide defaults.

- Add Reset Settings dialog actions for the active settings menu and all project settings.
- Track section-owned settings keys in a shared scope-aware registry and reuse it for save/reset validation.
- Cover modal/mobile reset behavior, registry invariants, docs, i18n strings, and a release changeset.

Files changed:
 .changeset/fn-7506-settings-reset.md               |   7 +
 docs/dashboard-guide.md                            |  12 +
 .../dashboard/app/components/SettingsModal.css     |  33 +++
 .../dashboard/app/components/SettingsModal.tsx     | 215 +++++++++++++++++-
 .../__tests__/SettingsModal.general.test.tsx       | 145 ++++++++++++
 .../components/__tests__/settings-mobile.test.tsx  |  37 +++-
 .../settings/__tests__/section-keys.test.ts        | 171 ++++++++++++++
 .../app/components/settings/save-split.ts          |   9 +-
 .../app/components/settings/section-keys.ts        | 246 +++++++++++++++++++++
 packages/i18n/locales/en/app.json                  |  11 +
 packages/i18n/locales/zh-CN/app.json               |  11 +
 11 files changed, 886 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7506
Fusion-Task-Lineage: a627e820-fe3b-42c5-adf2-97469bbe7a3b
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 13:35:40 -07:00
parent 81f2053921
commit 87a700cd4d
11 changed files with 886 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Reset Settings button to restore a menu's or all project settings to defaults.
category: feature
dev: New tested section→keys (scope-aware) registry (packages/dashboard/app/components/settings/section-keys.ts) drives per-menu reset via updateSettings/updateGlobalSettings with null-as-delete; non-blob sections (secrets, MCP, plugins, memory, auth, prompts, CLI agents, runtimes) are excluded with a documented reason.

View File

@@ -16,6 +16,18 @@ Use **Search settings** at the top of Settings to find the section that contains
<!-- FNXC:SettingsDefaults 2026-07-04-00:00: FN-7505 requires every user-editable setting's help text to state its own default value, so operators reading a field's description know what it defaults to without checking the reference doc. -->
Every user-editable setting's help text (the `.settings-description`/`<small>` hint under a field) states its own default value — for example “Default: 3.”, “Default: enabled.”, or “No default — unset (inherits the global setting).” for values that fall back to another scope. Canonical default values come from `DEFAULT_GLOBAL_SETTINGS` / `DEFAULT_PROJECT_SETTINGS` in `packages/core/src/settings-schema.ts`; the dashboard copy never invents a number. A guard test (`settings-default-descriptions.test.tsx`) enforces that every surfaced setting states its default and that every `DEFAULT_SETTINGS` key is either documented or explicitly allowlisted as not surfaced in the Settings UI.
## Reset Settings
<!-- FNXC:SettingsResetDocs 2026-07-04-00:00: Reset Settings is a DESTRUCTIVE action. Document both choices, the scope-precision guarantee, and which sections are excluded so operators understand exactly what a reset does and does not touch before they click it. -->
The Settings footer includes a **Reset Settings** button, next to Import/Export, in both the Settings modal and the embedded Settings page (desktop and mobile). Selecting it opens a confirmation dialog with two destructive choices, plus Cancel:
- **Reset this menu ({{section}})** — resets only the settings owned by the currently active section, at that section's own scope (global or project). A global section (for example Appearance) writes the section's keys back to their canonical defaults. A project section (for example Merge) clears the section's keys back to their inherited/default value. No other section's settings are touched.
- **Reset all project settings** — resets every project-scoped setting for the current project back to its default/inherited value. This never touches global (cross-project) settings.
Both actions are irreversible; there is no undo after confirming. The dialog closes and the form refreshes to show the reset values immediately after a successful reset.
**Excluded sections.** Some sections are not a simple settings form and are intentionally excluded from **Reset this menu** (the button is disabled with an explanatory tooltip when one of these is the active section), because each already has its own dedicated management flow: **Secrets**, **MCP Servers** (global and project), **Plugins**, **Memory**, **Authentication**, **Prompts**, **CLI Agents**, and the **Hermes**/**OpenClaw**/**Paperclip** runtime sections. **Reset all project settings** is unaffected by this exclusion list since it resets the underlying project settings values directly, not through any of those sections' own flows.
## Keyboard shortcuts
<!-- FNXC:DashboardShortcuts 2026-07-04-00:00: Dashboard keyboard shortcuts are configurable global operator preferences. The docs must state the defaults, editable-field safety guard, duplicate/invalid save behavior, and one-popup Escape semantics so operators know why Space/Terminal/Escape act differently in text fields than on the board. -->

View File

@@ -1431,6 +1431,39 @@ FN-7453 keeps GitLab's enable switch visible while hiding noisy URL/token fields
margin: var(--space-lg) var(--space-xl);
}
/*
FNXC:SettingsReset 2026-07-04-00:45:
Reset Settings confirmation dialog (FN-7506). Reuses .modal/.modal-md/.modal-overlay/
.modal-actions chrome; only the choice-list layout and destructive-reason hint are new.
Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
*/
.settings-reset-dialog__choice {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-block-end: var(--space-md);
}
.settings-reset-dialog__choice-btn {
align-self: flex-start;
width: 100%;
}
.settings-reset-dialog__ineligible-reason {
color: var(--text-muted);
}
@media (max-width: 768px) {
.settings-reset-dialog {
max-width: calc(100vw - var(--space-md));
}
.settings-reset-dialog__choice-btn {
/* Full-width tappable target at narrow widths; avoids horizontal overflow. */
width: 100%;
}
}
@media (max-width: 768px) {
.settings-note {
padding: 0 var(--space-lg);

View File

@@ -7,9 +7,15 @@ import {
normalizeMergeAdvanceAutoSyncMode,
} 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 type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
import { splitSettingsSave } from "./settings/save-split";
import {
ALL_PROJECT_RESET_KEYS,
getResetIneligibleReason,
getSectionKeyEntry,
} from "./settings/section-keys";
import { describeShortcutValidation, normalizeKeyboardShortcut } from "../utils/keyboardShortcuts";
import type { SectionSaveHandler } from "./settings/sections/context";
import { AppearanceSection } from "./settings/sections/AppearanceSection";
@@ -930,6 +936,14 @@ export function SettingsModal({
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState<number | null>(null);
/*
FNXC:SettingsReset 2026-07-04-00:20:
Reset Settings confirmation dialog state (FN-7506). `resetInFlight` guards both
destructive actions against double-submit while the reset write + form refresh
are in progress, mirroring the `isSaving` guard on the Save action.
*/
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const [resetInFlight, setResetInFlight] = useState(false);
const {
entries: overlapPathPickerEntries,
@@ -1139,9 +1153,17 @@ export function SettingsModal({
return () => mediaQuery.removeEventListener("change", updateMobilePicker);
}, []);
useEffect(() => {
/*
FNXC:SettingsReset 2026-07-04-00:15:
Factored out of the initial-load effect so the FN-7506 reset handlers can
re-fetch and re-normalize the merged + scoped settings after a reset write,
refreshing the form to the just-reset values without duplicating the
normalization logic. `showLoadingState` is false for post-reset refreshes so
the whole modal doesn't flash back to the loading spinner.
*/
const refreshSettingsForm = useCallback((showLoadingState: boolean) => {
// Load both merged and scoped settings to enable inheritance detection
Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)])
return Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)])
.then(([s, scoped]) => {
const normalizedSettings = {
...s,
@@ -1184,14 +1206,22 @@ export function SettingsModal({
: normalizeMergeAdvanceAutoSyncMode(scoped.project.mergeAdvanceAutoSync),
},
}); // Store initial scoped values for null-as-delete
setLoading(false);
if (showLoadingState) {
setLoading(false);
}
})
.catch((err) => {
addToast(getErrorMessage(err), "error");
setLoading(false);
if (showLoadingState) {
setLoading(false);
}
});
}, [addToast, projectId]);
useEffect(() => {
void refreshSettingsForm(true);
}, [addToast, projectId]);
useEffect(() => {
if (activeSection !== "scheduling" || hasFetchedGlobalConcurrencyRef.current) {
return;
@@ -2251,14 +2281,16 @@ export function SettingsModal({
}, [favoriteModels, favoriteProviders]);
// Modal-only: Escape dismisses the dialog. Embedded view is navigated away via the left sidebar, not Escape.
// FNXC:SettingsReset 2026-07-04-00:30: Skipped while the Reset Settings confirmation dialog is
// open so Escape closes only that dialog (its own listener below), not the whole Settings modal.
useEffect(() => {
if (!escapeEnabled) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Escape" && !resetDialogOpen) onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, escapeEnabled]);
}, [onClose, escapeEnabled, resetDialogOpen]);
// Modal-only: backdrop click dismisses. Embedded view has no overlay backdrop.
const modalOverlayDismissProps = useOverlayDismiss(onClose);
@@ -2738,6 +2770,96 @@ export function SettingsModal({
}
}, [form, globalGitlabSettings, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection, isSaving, t]);
/*
FNXC:SettingsReset 2026-07-04-00:25:
"Reset this menu" resolves the active section's { scope, keys } from the
shared section-keys registry (packages/dashboard/app/components/settings/section-keys.ts)
and writes ONLY those keys, at the correct scope, through the SAME
updateGlobalSettings/updateSettings plumbing (and null-as-delete convention)
used by handleSave/splitSettingsSave. GLOBAL keys reset to the canonical
DEFAULT_GLOBAL_SETTINGS value; PROJECT keys reset via null-as-delete so an
overridable project setting reverts to its inherited/default value. The form
is refreshed afterward via refreshSettingsForm so fields immediately show the
reset values.
*/
const activeSectionResetEntry = useMemo(() => getSectionKeyEntry(activeSection), [activeSection]);
const activeSectionResetIneligibleReason = useMemo(() => getResetIneligibleReason(activeSection), [activeSection]);
const activeSectionLabel = useMemo(() => {
const section = SETTINGS_SECTIONS.find((s) => s.id === activeSection);
return section ? t(section.labelKey, section.label) : activeSection;
}, [activeSection, t]);
const handleResetActiveSection = useCallback(async () => {
if (resetInFlight || !activeSectionResetEntry) return;
setResetInFlight(true);
try {
if (activeSectionResetEntry.scope === "global") {
const patch: Record<string, unknown> = {};
for (const key of activeSectionResetEntry.keys) {
patch[key] = (DEFAULT_GLOBAL_SETTINGS as Record<string, unknown>)[key];
}
await updateGlobalSettings(patch);
} else {
const patch: Record<string, unknown> = {};
for (const key of activeSectionResetEntry.keys) {
patch[key] = null; // null-as-delete: revert to inherited/default project value
}
await updateSettings(patch, projectId);
}
await refreshSettingsForm(false);
addToast(t("settings.reset.menuResetSuccess", "{{section}} settings reset to defaults", { section: activeSectionLabel }), "success");
setResetDialogOpen(false);
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setResetInFlight(false);
}
}, [resetInFlight, activeSectionResetEntry, projectId, refreshSettingsForm, addToast, t, activeSectionLabel]);
const handleResetAllProjectSettings = useCallback(async () => {
if (resetInFlight) return;
setResetInFlight(true);
try {
const patch: Record<string, unknown> = {};
for (const key of ALL_PROJECT_RESET_KEYS) {
patch[key] = null; // null-as-delete: never touches global keys
}
await updateSettings(patch, projectId);
await refreshSettingsForm(false);
addToast(t("settings.reset.allProjectResetSuccess", "All project settings reset to defaults"), "success");
setResetDialogOpen(false);
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setResetInFlight(false);
}
}, [resetInFlight, projectId, refreshSettingsForm, addToast, t]);
const closeResetDialog = useCallback(() => {
if (resetInFlight) return;
setResetDialogOpen(false);
}, [resetInFlight]);
const handleResetDialogOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
closeResetDialog();
}
}, [closeResetDialog]);
// Reset dialog gets its own Escape handler (takes precedence over the modal-level
// Escape-to-close so Escape closes only the confirmation dialog, not the whole modal).
useEffect(() => {
if (!resetDialogOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
closeResetDialog();
}
};
document.addEventListener("keydown", handleKey, true);
return () => document.removeEventListener("keydown", handleKey, true);
}, [resetDialogOpen, closeResetDialog]);
const handleSaveMemory = useCallback(async () => {
try {
await saveMemoryFile(selectedMemoryPath, memoryContent, projectId);
@@ -3594,6 +3716,23 @@ export function SettingsModal({
>
{importLoading ? t("settings.importExport.loadingFile", "Loading…") : t("settings.importExport.importBtn", "Import")}
</button>
{/*
FNXC:SettingsReset 2026-07-04-00:35:
Reset Settings lives in the footer next to Import/Export in BOTH the modal and
embedded (SettingsView) presentations — the footer is not gated by isEmbedded
(only Cancel is), so this button renders in both automatically (FN-7506
Surface Enumeration: modal + embedded).
*/}
<button
type="button"
className="btn btn-sm"
data-testid="settings-reset"
onClick={() => setResetDialogOpen(true)}
disabled={loading}
title={t("settings.reset.buttonTitle", "Reset settings to their defaults")}
>
{t("settings.reset.button", "Reset Settings")}
</button>
</div>
<div className="modal-actions-right">
{/* FNXC:Settings 2026-06-22-00:00: Cancel/close is a dialog affordance; the embedded main view is left via the sidebar, so it shows only Save. */}
@@ -3840,6 +3979,70 @@ export function SettingsModal({
</div>
</div>
)}
{/*
FNXC:SettingsReset 2026-07-04-00:40:
Reset Settings confirmation dialog (FN-7506). Mirrors the overlap-path-picker
modal-overlay dialog pattern in this file: role="dialog", aria-modal, aria-label,
overlay-click-to-cancel, and its own Escape handler (registered above). Offers two
destructive choices — reset the active section only (disabled/explained when the
section is excluded/non-key) and reset all project settings — plus Cancel.
*/}
{resetDialogOpen && (
<div
className="modal-overlay open"
onClick={handleResetDialogOverlayClick}
role="dialog"
aria-modal="true"
aria-label={t("settings.reset.dialogAriaLabel", "Reset settings")}
data-testid="settings-reset-dialog"
>
<div className="modal modal-md settings-reset-dialog" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("settings.reset.dialogTitle", "Reset Settings")}</h3>
<button className="modal-close" onClick={closeResetDialog} aria-label={t("actions.close", "Close")}>
&times;
</button>
</div>
<div className="modal-body">
<p>{t("settings.reset.dialogBody", "Choose what to reset to its defaults. This cannot be undone.")}</p>
<div className="settings-reset-dialog__choice">
<button
type="button"
className="btn btn-danger settings-reset-dialog__choice-btn"
data-testid="settings-reset-menu"
onClick={() => void handleResetActiveSection()}
disabled={resetInFlight || !activeSectionResetEntry}
title={activeSectionResetIneligibleReason}
>
{t("settings.reset.resetMenuAction", "Reset this menu ({{section}})", { section: activeSectionLabel })}
</button>
{activeSectionResetIneligibleReason && (
<small className="settings-reset-dialog__ineligible-reason">{activeSectionResetIneligibleReason}</small>
)}
</div>
<div className="settings-reset-dialog__choice">
<button
type="button"
className="btn btn-danger settings-reset-dialog__choice-btn"
data-testid="settings-reset-all-project"
onClick={() => void handleResetAllProjectSettings()}
disabled={resetInFlight}
>
{t("settings.reset.resetAllProjectAction", "Reset all project settings")}
</button>
</div>
</div>
<div className="modal-actions">
<div className="modal-actions-right">
<button className="btn btn-sm" onClick={closeResetDialog} disabled={resetInFlight}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1505,5 +1505,150 @@ describe("SettingsModal", () => {
expect(payload).toEqual(expect.objectContaining({ dashboardFontScalePct: 120 }));
});
});
/*
FNXC:SettingsReset 2026-07-04-00:50:
FN-7506 Reset Settings coverage: dialog open/close (button, Cancel, overlay, Escape) without
mutating settings; both destructive actions present and correctly labeled; per-menu reset
disabled with a reason for an excluded/non-key section; SCOPE PRECISION for a project section
(merge), a global section (appearance), and "reset all project settings" (project keys only,
never global); and the form refetches/re-renders after a reset.
*/
describe("Reset Settings", () => {
it("renders the Reset Settings button in both modal and embedded presentations", async () => {
const { unmount } = renderModal();
await waitForSettingsModalReady();
expect(screen.getByTestId("settings-reset")).toBeInTheDocument();
unmount();
renderModal({ presentation: "embedded" });
await waitForSettingsModalReady();
expect(screen.getByTestId("settings-reset")).toBeInTheDocument();
});
it("opens a dialog with both destructive actions and Cancel, without mutating settings", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
const dialog = screen.getByTestId("settings-reset-dialog");
expect(dialog).toHaveAttribute("role", "dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-label");
expect(screen.getByTestId("settings-reset-menu")).toHaveTextContent(/Reset this menu/i);
expect(screen.getByTestId("settings-reset-all-project")).toHaveTextContent(/Reset all project settings/i);
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
});
it("Cancel closes the dialog without mutating settings", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
const dialog = screen.getByTestId("settings-reset-dialog");
expect(dialog).toBeInTheDocument();
await settingsModalUser.click(within(dialog).getByRole("button", { name: /^Cancel$/ }));
expect(screen.queryByTestId("settings-reset-dialog")).not.toBeInTheDocument();
expect(mockUpdateSettings).not.toHaveBeenCalled();
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
});
it("overlay click closes the dialog without mutating settings", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
fireEvent.click(screen.getByTestId("settings-reset-dialog"));
expect(screen.queryByTestId("settings-reset-dialog")).not.toBeInTheDocument();
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it("Escape closes only the reset dialog, not the whole Settings modal", async () => {
const onClose = vi.fn();
renderModal({ initialSection: "general", onClose });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("settings-reset-dialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it("disables per-menu reset with a documented reason for an excluded/non-key section (Secrets)", async () => {
renderModal({ initialSection: "secrets" });
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
await settingsModalUser.click(await screen.findByTestId("settings-reset"));
const menuBtn = screen.getByTestId("settings-reset-menu");
expect(menuBtn).toBeDisabled();
expect(menuBtn).toHaveAttribute("title");
expect(menuBtn.getAttribute("title")).toBeTruthy();
});
it("SCOPE PRECISION: per-menu reset of a project section (Merge) writes only its keys via updateSettings, never updateGlobalSettings", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
await settingsModalUser.click(screen.getByTestId("settings-reset-menu"));
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.autoMerge).toBeNull();
expect(payload.mergeStrategy).toBeNull();
expect(payload.gitlabAuthToken).toBeNull();
// Not part of "merge" — owned by "general" instead; must not leak in.
expect(payload).not.toHaveProperty("gitlabEnabled");
expect(payload).not.toHaveProperty("taskPrefix");
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
});
it("SCOPE PRECISION: per-menu reset of a global section (Appearance) writes only its keys via updateGlobalSettings, never updateSettings", async () => {
renderModal({ initialSection: "appearance" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
await settingsModalUser.click(screen.getByTestId("settings-reset-menu"));
await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled());
const payload = mockUpdateGlobalSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload).toEqual(
expect.objectContaining({
themeMode: "system",
colorTheme: "shadcn-ember",
}),
);
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it("reset all project settings writes only project keys via updateSettings and never touches updateGlobalSettings", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByTestId("settings-reset"));
await settingsModalUser.click(screen.getByTestId("settings-reset-all-project"));
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.taskPrefix).toBeNull();
expect(payload.autoMerge).toBeNull();
expect(payload.maxConcurrent).toBeNull();
// Global-only key must never appear in a project-scope reset payload.
expect(payload).not.toHaveProperty("themeMode");
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
});
it("refreshes the form after a successful reset (refetches settings) and closes the dialog", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const fetchCallsBefore = mockFetchSettings.mock.calls.length;
await settingsModalUser.click(screen.getByTestId("settings-reset"));
await settingsModalUser.click(screen.getByTestId("settings-reset-menu"));
await waitFor(() => expect(mockFetchSettings.mock.calls.length).toBeGreaterThan(fetchCallsBefore));
await waitFor(() => expect(screen.queryByTestId("settings-reset-dialog")).not.toBeInTheDocument());
});
});
});

View File

@@ -201,6 +201,31 @@ describe("SettingsModal mobile adaptations", () => {
expect(container.querySelector(".settings-content")).toBeTruthy();
});
/*
FNXC:SettingsReset 2026-07-04-01:00:
FN-7506 mobile surface coverage: the Reset Settings button and its confirmation
dialog must be reachable and usable at the mobile breakpoint, with no horizontal
overflow, mirroring the desktop assertions in SettingsModal.general.test.tsx.
*/
it("renders and operates the Reset Settings button/dialog at the mobile breakpoint", async () => {
mockSettingsViewport(true);
const { findByTestId, container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const resetBtn = await findByTestId("settings-reset");
expect(container.querySelector(".modal-actions")?.contains(resetBtn)).toBe(true);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(resetBtn);
const dialog = await findByTestId("settings-reset-dialog");
expect(dialog).toBeTruthy();
expect(dialog.querySelector(".settings-reset-dialog")).toBeTruthy();
expect(within(dialog).getByTestId("settings-reset-menu")).toBeTruthy();
expect(within(dialog).getByTestId("settings-reset-all-project")).toBeTruthy();
expect(updateSettings).not.toHaveBeenCalled();
});
it("renders the app version label in mobile layout", async () => {
mockSettingsViewport(true);
const { findByText, container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
@@ -372,11 +397,11 @@ describe("SettingsModal mobile adaptations", () => {
expect(within(document.body).getByLabelText("Max Duration (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
expect(within(document.body).getByLabelText("Request Timeout (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
const projectSourceGrid = within(document.body).getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
const projectSourceGrid = within(document.body).getByRole("checkbox", { name: /^Page Fetch/ }).closest(".settings-research-source-grid");
expect(projectSourceGrid).toBeTruthy();
expect(within(document.body).getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: /^GitHub/ }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: /^Local Docs/ }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: /^LLM Synthesis/ }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
});
it("renders settings nav items with active class for touch styling", async () => {
@@ -494,6 +519,10 @@ describe("SettingsModal mobile adaptations", () => {
expectMobileRule(css, ".settings-research-source-grid", "grid-template-columns: 1fr;");
expectMobileRule(css, ".settings-research-limits-grid", "grid-template-columns: 1fr;");
// FN-7506: Reset Settings dialog mobile overrides — full-width tappable choices, no horizontal overflow.
expectMobileRule(css, ".settings-reset-dialog", "max-width: calc(100vw - var(--space-md));");
expectMobileRule(css, ".settings-reset-dialog__choice-btn", "width: 100%;");
// Base rules: desktop uses --space-xl horizontal margin for remote header elements
expectBaseRule(css, ".remote-status-bar", "margin: 0 var(--space-xl) var(--space-md);");
expectBaseRule(css, ".remote-share-block", "margin: 0 var(--space-xl) var(--space-md);");

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
import {
ALL_PROJECT_RESET_KEYS,
EXCLUDED_RESET_SECTIONS,
getResetIneligibleReason,
getSectionKeyEntry,
isRegistryKeyValidForScope,
isResetEligibleSection,
} from "../section-keys";
const GLOBAL_KEY_SET = new Set<string>(GLOBAL_SETTINGS_KEYS as readonly string[]);
const PROJECT_KEY_SET = new Set<string>(PROJECT_SETTINGS_KEYS as readonly string[]);
/** Every key-owning section id we expect the registry to resolve, with its declared scope. */
const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
// global sections (reused from GLOBAL_SECTION_KEYS in save-split.ts)
appearance: "global",
notifications: "global",
experimental: "global",
"global-general": "global",
"global-models": "global",
"node-sync": "global",
"research-global": "global",
remote: "global",
// project sections (new for FN-7506)
general: "project",
commands: "project",
worktrees: "project",
scheduling: "project",
"scheduled-evals": "project",
"node-routing": "project",
merge: "project",
"agent-permissions": "project",
backups: "project",
"research-project": "project",
"project-models": "project",
};
const EXPECTED_EXCLUDED_SECTIONS = [
"secrets",
"global-mcp",
"mcp",
"plugins",
"memory",
"authentication",
"prompts",
"cli-agents",
"hermes-runtime",
"openclaw-runtime",
"paperclip-runtime",
];
describe("settings section-keys registry", () => {
it("resolves every expected key-owning section with the correct scope", () => {
for (const [sectionId, scope] of Object.entries(EXPECTED_KEY_OWNING_SECTIONS)) {
const entry = getSectionKeyEntry(sectionId);
expect(entry, `expected ${sectionId} to be reset-eligible`).not.toBeNull();
expect(entry!.scope).toBe(scope);
expect(entry!.keys.length).toBeGreaterThan(0);
expect(isResetEligibleSection(sectionId)).toBe(true);
}
});
it("every registry key is a real member of the canonical scope key set matching its declared scope", () => {
for (const [sectionId, scope] of Object.entries(EXPECTED_KEY_OWNING_SECTIONS)) {
const entry = getSectionKeyEntry(sectionId)!;
for (const key of entry.keys) {
const validForDeclaredScope = isRegistryKeyValidForScope(key, scope);
expect(
validForDeclaredScope,
`section "${sectionId}" claims key "${key}" at scope "${scope}", but it is not a member of the matching ${scope === "global" ? "GLOBAL_SETTINGS_KEYS" : "PROJECT_SETTINGS_KEYS"} set`,
).toBe(true);
if (scope === "global") {
expect(GLOBAL_KEY_SET.has(key)).toBe(true);
} else {
expect(PROJECT_KEY_SET.has(key)).toBe(true);
}
}
}
});
it("no key is claimed by two sections at the same scope", () => {
const seenAtScope: Record<"global" | "project", Map<string, string>> = {
global: new Map(),
project: new Map(),
};
for (const [sectionId, scope] of Object.entries(EXPECTED_KEY_OWNING_SECTIONS)) {
const entry = getSectionKeyEntry(sectionId)!;
for (const key of entry.keys) {
const owner = seenAtScope[scope].get(key);
expect(
owner,
`key "${key}" at scope "${scope}" is claimed by both "${owner}" and "${sectionId}"`,
).toBeUndefined();
seenAtScope[scope].set(key, sectionId);
}
}
});
it("excludes non-key sections explicitly, with a documented reason, and marks them reset-ineligible", () => {
for (const sectionId of EXPECTED_EXCLUDED_SECTIONS) {
expect(getSectionKeyEntry(sectionId)).toBeNull();
expect(isResetEligibleSection(sectionId)).toBe(false);
expect(getResetIneligibleReason(sectionId)).toBeTruthy();
expect(EXCLUDED_RESET_SECTIONS[sectionId]).toBeTruthy();
}
});
it("treats unknown/group-header section ids as reset-ineligible without a reason", () => {
expect(getSectionKeyEntry("__project_header")).toBeNull();
expect(isResetEligibleSection("__project_header")).toBe(false);
expect(getResetIneligibleReason("__project_header")).toBeUndefined();
});
it("a representative project section (merge) maps to its expected owned keys", () => {
const entry = getSectionKeyEntry("merge")!;
expect(entry.scope).toBe("project");
expect(new Set(entry.keys)).toEqual(
new Set([
"autoMerge",
"autoResolveConflicts",
"commitAuthorEmail",
"commitAuthorEnabled",
"commitAuthorName",
"directMergeCommitStrategy",
"githubAuthMode",
"githubAuthToken",
"gitlabAuthToken",
"gitlabAuthTokenType",
"includeTaskIdInCommit",
"integrationBranch",
"maxAutoMergeRetries",
"mergeAdvanceAutoSync",
"mergeConflictStrategy",
"mergeIntegrationWorktree",
"mergeStrategy",
"mergeStrategyOverlapBehavior",
"merger",
"planApprovalMode",
"postMergeAuditMode",
"pushAfterMerge",
"pushRemote",
"smartConflictResolution",
"testMode",
]),
);
// gitlabEnabled's enable+URL fields are owned by "general" instead, not duplicated here.
expect(entry.keys).not.toContain("gitlabEnabled");
});
it("a representative global section (appearance) maps to its expected owned keys", () => {
const entry = getSectionKeyEntry("appearance")!;
expect(entry.scope).toBe("global");
expect(new Set(entry.keys)).toEqual(
new Set(["themeMode", "colorTheme", "dashboardFontScalePct", "shadcnCustomColors"]),
);
});
it("ALL_PROJECT_RESET_KEYS contains only project keys and never global-only keys", () => {
expect(ALL_PROJECT_RESET_KEYS.length).toBeGreaterThan(0);
for (const key of ALL_PROJECT_RESET_KEYS) {
expect(PROJECT_KEY_SET.has(key)).toBe(true);
}
// Sanity: a couple of known global-only keys must not sneak into the project set.
expect(ALL_PROJECT_RESET_KEYS).not.toContain("themeMode");
expect(ALL_PROJECT_RESET_KEYS).not.toContain("ntfyEnabled");
});
});

View File

@@ -48,7 +48,14 @@ const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
type RemoteAccessProvider = "tailscale" | "cloudflare";
type RemoteAccessPatch = NonNullable<GlobalSettings["remoteAccess"]>;
const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
/*
FNXC:SettingsReset 2026-07-04-00:00:
Exported (not just module-private) so the FN-7506 section-keys registry
(settings/section-keys.ts) can reuse this as the single source of truth for
which GLOBAL keys belong to which settings section, instead of duplicating
the list for the "Reset this menu" feature.
*/
export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
appearance: new Set([
"themeMode",
"colorTheme",

View File

@@ -0,0 +1,246 @@
/**
* Section -> owned settings keys (+ scope) registry (FN-7506).
*
* FNXC:SettingsReset 2026-07-04-00:00:
* "Reset this menu" in the Settings footer must touch ONLY the active
* section's own keys, at the correct scope (global vs project), and must
* never silently reset a section that isn't a simple settings blob. This
* module is the single source of truth for that mapping so the reset flow
* and `splitSettingsSave` (save-split.ts) never diverge on which keys belong
* to which section. Global-section entries are re-exported from
* `GLOBAL_SECTION_KEYS` in save-split.ts (do not duplicate that list here);
* this file adds the missing PROJECT-section entries and the shared
* exclusion list.
*
* Design decisions (recorded in the `plan` task document for FN-7506):
* 1. A key-owning section maps to { scope, keys }. Every key here MUST be a
* real member of GLOBAL_SETTINGS_KEYS or PROJECT_SETTINGS_KEYS matching
* the declared scope (enforced by section-keys.test.ts).
* 2. Non-key sections (secrets, global-mcp, mcp, plugins, memory,
* authentication, prompts, cli-agents, and the three runtime sections)
* are NOT a simple settings blob — they are managed by their own CRUD
* flows/routes. They are explicitly EXCLUDED from per-menu reset rather
* than silently reset. See EXCLUDED_RESET_SECTIONS below.
* 3. Reset semantics: GLOBAL keys reset to the canonical
* `DEFAULT_GLOBAL_SETTINGS` value (write). PROJECT keys reset via
* null-as-delete (write `null`) so an inherited/overridable project
* setting reverts to its inherited/default value, matching the existing
* null-as-delete convention already used by `splitSettingsSave`.
* 4. Some field names are edited from more than one section in the UI
* (e.g. `gitlabEnabled`'s enable toggle + URL fields live in "general",
* while its auth token fields live in "merge"). Each such key is
* assigned to exactly ONE canonical owning section below to keep every
* section's reset scoped to a disjoint key set; see the inline notes.
*/
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
import { GLOBAL_SECTION_KEYS, MODEL_LANE_KEYS } from "./save-split";
export type SettingsResetScope = "global" | "project";
export interface SectionKeyEntry {
scope: SettingsResetScope;
keys: readonly string[];
}
/**
* Project-scope section -> owned key registry. Reuses MODEL_LANE_KEYS from
* save-split.ts for the project-models lane overrides instead of duplicating
* them.
*/
const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
general: [
"allowAbsoluteFileBrowserPaths",
"capacityRiskBannerEnabled",
"capacityRiskTodoThreshold",
"chatAutoCleanupDays",
"chatRoomCompactionFetchLimit",
"chatRoomRecentVerbatimMessages",
"chatRoomSummaryMaxChars",
"completionDocumentationMode",
"enabledBuiltinWorkflowIds",
"ephemeralAgentsCanCreateTasks",
"ephemeralAgentsEnabled",
"githubLinkImportedIssuesToTracking",
"githubTrackingDedupEnabled",
"githubTrackingDefaultRepo",
"githubTrackingEnabledByDefault",
// gitlabEnabled/gitlabInstanceUrl/gitlabApiBaseUrl's enable+URL fields are
// owned here; gitlabAuthToken/gitlabAuthTokenType are owned by "merge".
"gitlabApiBaseUrl",
"gitlabEnabled",
"gitlabInstanceUrl",
"mailAutoCleanupDays",
"operationalLogRetentionDays",
"quickChatButtonMode",
"quickChatCloseOnOutsideClick",
"showQuickChatFAB",
"showTaskChatsInCommonFeed",
"taskPrefix",
"workspaceMode",
],
commands: ["buildCommand", "testCommand"],
worktrees: [
"executorAllowSiblingBranchRename",
"maxWorktrees",
"recycleWorktrees",
"showWorktreeGrouping",
"worktreeCopyFiles",
"worktreeInitCommand",
"worktreeNaming",
"worktreeRebaseBeforeMerge",
"worktreeRebaseLocalBase",
"worktreeRebaseRemote",
"worktreesDir",
"worktrunk",
],
scheduling: [
"archiveAgentLogMode",
"autoArchiveDoneAfterMs",
"autoArchiveDoneTasksEnabled",
"engineerBacklogAutoClaim",
"groupOverlappingFiles",
"heartbeatScopeDiscipline",
"ignoreHiddenOverlapPaths",
"maxConcurrent",
"maxStuckKills",
"maxTriageConcurrent",
"overlapIgnorePaths",
"pollIntervalMs",
"preserveProgressOnStuckRequeue",
"specStalenessEnabled",
"specStalenessMaxAgeMs",
"staleHighFanoutBlockerAgeThresholdMs",
"taskStuckTimeoutMs",
],
"scheduled-evals": ["evalSettings"],
"node-routing": ["defaultNodeId", "unavailableNodePolicy"],
merge: [
"autoMerge",
"autoResolveConflicts",
"commitAuthorEmail",
"commitAuthorEnabled",
"commitAuthorName",
"directMergeCommitStrategy",
"githubAuthMode",
"githubAuthToken",
// gitlabAuthToken/gitlabAuthTokenType are owned here; gitlabEnabled's
// enable+URL fields are owned by "general" (see above).
"gitlabAuthToken",
"gitlabAuthTokenType",
"includeTaskIdInCommit",
"integrationBranch",
"maxAutoMergeRetries",
"mergeAdvanceAutoSync",
"mergeConflictStrategy",
"mergeIntegrationWorktree",
"mergeStrategy",
"mergeStrategyOverlapBehavior",
"merger",
"planApprovalMode",
"postMergeAuditMode",
"pushAfterMerge",
"pushRemote",
"smartConflictResolution",
"testMode",
],
"agent-permissions": ["agentProvisioning", "defaultAgentPermissionPolicy"],
backups: [
"autoBackupDir",
"autoBackupEnabled",
"autoBackupRetention",
"autoBackupSchedule",
"memoryBackupDir",
"memoryBackupEnabled",
"memoryBackupRetention",
"memoryBackupSchedule",
"memoryBackupScope",
],
"research-project": ["researchSettings"],
"project-models": [
"autoSelectModelPreset",
"autoSummarizeTitles",
"defaultPresetBySize",
"defaultWorkflowId",
"modelPresets",
"prDescriptionPromptInstructions",
"prTitlePromptInstructions",
"tokenCap",
"useAiMergeCommitSummary",
...MODEL_LANE_KEYS,
],
};
/**
* Non-key sections that are NOT a simple settings blob. Each is managed by
* its own dedicated flow/routes (secrets store, MCP server CRUD, plugin
* manager, memory editor, auth/OAuth, prompt library, CLI adapter approvals,
* plugin runtime config), so a generic "reset to defaults" over the merged
* settings form would be meaningless or actively destructive. Per-menu reset
* is disabled for these with a documented reason (surfaced in the dialog).
*/
export const EXCLUDED_RESET_SECTIONS: Record<string, string> = {
secrets: "Secrets are managed by the Secrets store, not the settings form.",
"global-mcp": "MCP servers are managed by their own add/edit/remove flow.",
mcp: "MCP servers are managed by their own add/edit/remove flow.",
plugins: "Plugins and Pi extensions are managed by the Plugin Manager.",
memory: "Memory files are edited directly, not as a settings blob.",
authentication: "Authentication/provider credentials are managed by their own OAuth/API-key flow.",
prompts: "Prompt library entries are managed by their own editor, not bulk-reset here.",
"cli-agents": "Per-adapter CLI agent settings are managed by their own approval/config flow.",
"hermes-runtime": "Runtime plugin settings are managed by the plugin's own config surface.",
"openclaw-runtime": "Runtime plugin settings are managed by the plugin's own config surface.",
"paperclip-runtime": "Runtime plugin settings are managed by the plugin's own config surface.",
};
/**
* Resolve the { scope, keys } entry for a key-owning section id, or `null`
* for excluded/non-key/group-header sections.
*/
export function getSectionKeyEntry(sectionId: string): SectionKeyEntry | null {
/*
FNXC:SettingsReset 2026-07-04-00:10:
Exclusions are checked FIRST because a couple of section ids collide across
the two lookup tables for unrelated reasons: "global-mcp" has an entry in
GLOBAL_SECTION_KEYS (used by splitSettingsSave to gate the normal Save flow)
but is explicitly excluded from RESET because MCP servers are managed by
their own CRUD flow, not a bulk reset. "project-models" also has an entry in
GLOBAL_SECTION_KEYS (its dual-scope global lane baselines) but for reset
purposes only its project-owned keys are touched, so PROJECT_SECTION_KEYS
is checked before GLOBAL_SECTION_KEYS.
*/
if (EXCLUDED_RESET_SECTIONS[sectionId]) {
return null;
}
const projectKeys = PROJECT_SECTION_KEYS[sectionId];
if (projectKeys) {
return { scope: "project", keys: projectKeys };
}
const globalKeys = GLOBAL_SECTION_KEYS[sectionId];
if (globalKeys) {
return { scope: "global", keys: Array.from(globalKeys) };
}
return null;
}
/** True when a section id has no reset-eligible key set (excluded or unknown/group-header). */
export function isResetEligibleSection(sectionId: string): boolean {
return getSectionKeyEntry(sectionId) !== null;
}
/** Human-readable reason a section's per-menu reset is disabled, or undefined if it is eligible. */
export function getResetIneligibleReason(sectionId: string): string | undefined {
return EXCLUDED_RESET_SECTIONS[sectionId];
}
/** Every PROJECT_SETTINGS_KEYS member, used for "reset all project settings". */
export const ALL_PROJECT_RESET_KEYS: readonly string[] = PROJECT_SETTINGS_KEYS;
/** Exposed for tests: validates every registry key against the canonical scope key sets. */
export function isRegistryKeyValidForScope(key: string, scope: SettingsResetScope): boolean {
if (scope === "global") {
return (GLOBAL_SETTINGS_KEYS as readonly string[]).includes(key);
}
return (PROJECT_SETTINGS_KEYS as readonly string[]).includes(key);
}
export { GLOBAL_SECTION_KEYS, MODEL_LANE_KEYS };

View File

@@ -6672,6 +6672,17 @@
},
"modelPricing": {
"description": "Override per-1M token rates used by Command Center cost estimates. Overrides win over the built-in baseline; unlisted models still use the baseline. No default — unset (no overrides)."
},
"reset": {
"button": "Reset Settings",
"buttonTitle": "Reset settings to their defaults",
"dialogAriaLabel": "Reset settings",
"dialogTitle": "Reset Settings",
"dialogBody": "Choose what to reset to its defaults. This cannot be undone.",
"resetMenuAction": "Reset this menu ({{section}})",
"resetAllProjectAction": "Reset all project settings",
"menuResetSuccess": "{{section}} settings reset to defaults",
"allProjectResetSuccess": "All project settings reset to defaults"
}
},
"setup": {

View File

@@ -6574,6 +6574,17 @@
"projectTitle": "Project MCP servers",
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers."
},
"reset": {
"button": "Reset Settings",
"buttonTitle": "Reset settings to their defaults",
"dialogAriaLabel": "Reset settings",
"dialogTitle": "Reset Settings",
"dialogBody": "Choose what to reset to its defaults. This cannot be undone.",
"resetMenuAction": "Reset this menu ({{section}})",
"resetAllProjectAction": "Reset all project settings",
"menuResetSuccess": "{{section}} settings reset to defaults",
"allProjectResetSuccess": "All project settings reset to defaults"
}
},
"setup": {