diff --git a/.changeset/fn-7506-settings-reset.md b/.changeset/fn-7506-settings-reset.md new file mode 100644 index 0000000000..8731353926 --- /dev/null +++ b/.changeset/fn-7506-settings-reset.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ab3d1f9908..7c7ff6347c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -16,6 +16,18 @@ Use **Search settings** at the top of Settings to find the section that contains Every user-editable setting's help text (the `.settings-description`/`` 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 + + +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 diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index c1f9450be8..15c1655abc 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -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); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index a46bfdf957..015456df3c 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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(null); const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false); const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState(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 = {}; + for (const key of activeSectionResetEntry.keys) { + patch[key] = (DEFAULT_GLOBAL_SETTINGS as Record)[key]; + } + await updateGlobalSettings(patch); + } else { + const patch: Record = {}; + 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 = {}; + 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) => { + 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")} + {/* + 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). + */} +
{/* 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({
)} + + {/* + 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 && ( +
+
event.stopPropagation()}> +
+

{t("settings.reset.dialogTitle", "Reset Settings")}

+ +
+
+

{t("settings.reset.dialogBody", "Choose what to reset to its defaults. This cannot be undone.")}

+
+ + {activeSectionResetIneligibleReason && ( + {activeSectionResetIneligibleReason} + )} +
+
+ +
+
+
+
+ +
+
+
+
+ )} ); } diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 20e025ef7e..70c4aaade7 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -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; + 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; + 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; + 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()); + }); + }); }); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index a55edf3986..26a0e4e79f 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -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(); + 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(); @@ -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);"); diff --git a/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts b/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts new file mode 100644 index 0000000000..fdd18c34af --- /dev/null +++ b/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts @@ -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(GLOBAL_SETTINGS_KEYS as readonly string[]); +const PROJECT_KEY_SET = new Set(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 = { + // 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> = { + 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"); + }); +}); diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index bc9d4dd249..782afe84cc 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -48,7 +48,14 @@ const MODEL_LANE_KEY_SET = new Set(MODEL_LANE_KEYS); type RemoteAccessProvider = "tailscale" | "cloudflare"; type RemoteAccessPatch = NonNullable; -const GLOBAL_SECTION_KEYS: Record> = { +/* +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> = { appearance: new Set([ "themeMode", "colorTheme", diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts new file mode 100644 index 0000000000..7737c97e16 --- /dev/null +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -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 = { + 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 = { + 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 }; diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 98f9cbe47a..2e621166f0 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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": { diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 06aa4b480e..f2c35f17cd 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -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": {