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:
7
.changeset/fn-7506-settings-reset.md
Normal file
7
.changeset/fn-7506-settings-reset.md
Normal 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.
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
246
packages/dashboard/app/components/settings/section-keys.ts
Normal file
246
packages/dashboard/app/components/settings/section-keys.ts
Normal 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 };
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user