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. -->
|
<!-- 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.
|
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
|
## 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. -->
|
<!-- 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);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.settings-note {
|
.settings-note {
|
||||||
padding: 0 var(--space-lg);
|
padding: 0 var(--space-lg);
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ import {
|
|||||||
normalizeMergeAdvanceAutoSyncMode,
|
normalizeMergeAdvanceAutoSyncMode,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
||||||
|
import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core";
|
||||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
|
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
|
||||||
import { splitSettingsSave } from "./settings/save-split";
|
import { splitSettingsSave } from "./settings/save-split";
|
||||||
|
import {
|
||||||
|
ALL_PROJECT_RESET_KEYS,
|
||||||
|
getResetIneligibleReason,
|
||||||
|
getSectionKeyEntry,
|
||||||
|
} from "./settings/section-keys";
|
||||||
import { describeShortcutValidation, normalizeKeyboardShortcut } from "../utils/keyboardShortcuts";
|
import { describeShortcutValidation, normalizeKeyboardShortcut } from "../utils/keyboardShortcuts";
|
||||||
import type { SectionSaveHandler } from "./settings/sections/context";
|
import type { SectionSaveHandler } from "./settings/sections/context";
|
||||||
import { AppearanceSection } from "./settings/sections/AppearanceSection";
|
import { AppearanceSection } from "./settings/sections/AppearanceSection";
|
||||||
@@ -930,6 +936,14 @@ export function SettingsModal({
|
|||||||
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
||||||
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
|
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
|
||||||
const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState<number | null>(null);
|
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 {
|
const {
|
||||||
entries: overlapPathPickerEntries,
|
entries: overlapPathPickerEntries,
|
||||||
@@ -1139,9 +1153,17 @@ export function SettingsModal({
|
|||||||
return () => mediaQuery.removeEventListener("change", updateMobilePicker);
|
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
|
// 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]) => {
|
.then(([s, scoped]) => {
|
||||||
const normalizedSettings = {
|
const normalizedSettings = {
|
||||||
...s,
|
...s,
|
||||||
@@ -1184,14 +1206,22 @@ export function SettingsModal({
|
|||||||
: normalizeMergeAdvanceAutoSyncMode(scoped.project.mergeAdvanceAutoSync),
|
: normalizeMergeAdvanceAutoSyncMode(scoped.project.mergeAdvanceAutoSync),
|
||||||
},
|
},
|
||||||
}); // Store initial scoped values for null-as-delete
|
}); // Store initial scoped values for null-as-delete
|
||||||
setLoading(false);
|
if (showLoadingState) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
addToast(getErrorMessage(err), "error");
|
addToast(getErrorMessage(err), "error");
|
||||||
setLoading(false);
|
if (showLoadingState) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [addToast, projectId]);
|
}, [addToast, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshSettingsForm(true);
|
||||||
|
}, [addToast, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "scheduling" || hasFetchedGlobalConcurrencyRef.current) {
|
if (activeSection !== "scheduling" || hasFetchedGlobalConcurrencyRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -2251,14 +2281,16 @@ export function SettingsModal({
|
|||||||
}, [favoriteModels, favoriteProviders]);
|
}, [favoriteModels, favoriteProviders]);
|
||||||
|
|
||||||
// Modal-only: Escape dismisses the dialog. Embedded view is navigated away via the left sidebar, not Escape.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!escapeEnabled) return;
|
if (!escapeEnabled) return;
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape" && !resetDialogOpen) onClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKey);
|
document.addEventListener("keydown", handleKey);
|
||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose, escapeEnabled]);
|
}, [onClose, escapeEnabled, resetDialogOpen]);
|
||||||
|
|
||||||
// Modal-only: backdrop click dismisses. Embedded view has no overlay backdrop.
|
// Modal-only: backdrop click dismisses. Embedded view has no overlay backdrop.
|
||||||
const modalOverlayDismissProps = useOverlayDismiss(onClose);
|
const modalOverlayDismissProps = useOverlayDismiss(onClose);
|
||||||
@@ -2738,6 +2770,96 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [form, globalGitlabSettings, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection, isSaving, t]);
|
}, [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 () => {
|
const handleSaveMemory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await saveMemoryFile(selectedMemoryPath, memoryContent, projectId);
|
await saveMemoryFile(selectedMemoryPath, memoryContent, projectId);
|
||||||
@@ -3594,6 +3716,23 @@ export function SettingsModal({
|
|||||||
>
|
>
|
||||||
{importLoading ? t("settings.importExport.loadingFile", "Loading…") : t("settings.importExport.importBtn", "Import")}
|
{importLoading ? t("settings.importExport.loadingFile", "Loading…") : t("settings.importExport.importBtn", "Import")}
|
||||||
</button>
|
</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>
|
||||||
<div className="modal-actions-right">
|
<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. */}
|
{/* 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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1505,5 +1505,150 @@ describe("SettingsModal", () => {
|
|||||||
expect(payload).toEqual(expect.objectContaining({ dashboardFontScalePct: 120 }));
|
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();
|
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 () => {
|
it("renders the app version label in mobile layout", async () => {
|
||||||
mockSettingsViewport(true);
|
mockSettingsViewport(true);
|
||||||
const { findByText, container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
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("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);
|
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(projectSourceGrid).toBeTruthy();
|
||||||
expect(within(document.body).getByRole("checkbox", { name: "GitHub" }).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: /^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: /^LLM Synthesis/ }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders settings nav items with active class for touch styling", async () => {
|
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-source-grid", "grid-template-columns: 1fr;");
|
||||||
expectMobileRule(css, ".settings-research-limits-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
|
// 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-status-bar", "margin: 0 var(--space-xl) var(--space-md);");
|
||||||
expectBaseRule(css, ".remote-share-block", "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 RemoteAccessProvider = "tailscale" | "cloudflare";
|
||||||
type RemoteAccessPatch = NonNullable<GlobalSettings["remoteAccess"]>;
|
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([
|
appearance: new Set([
|
||||||
"themeMode",
|
"themeMode",
|
||||||
"colorTheme",
|
"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": {
|
"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)."
|
"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": {
|
"setup": {
|
||||||
|
|||||||
@@ -6574,6 +6574,17 @@
|
|||||||
"projectTitle": "Project MCP servers",
|
"projectTitle": "Project MCP servers",
|
||||||
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
|
"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."
|
"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": {
|
"setup": {
|
||||||
|
|||||||
Reference in New Issue
Block a user