diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 825568fc81..bb12624fdd 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -17,7 +17,7 @@ Use **Search settings** at the top of Settings to find the section that contains On mobile, the search row starts collapsed behind a compact toggle icon beside the **Settings Section** dropdown to save vertical space; tap it to reveal the search input and tap again to hide it. An in-progress search query is preserved across collapse/expand. Desktop and tablet always show the search row with no toggle. -On desktop and tablet, the Settings navigation rail has no hard divider between navigation and content; section rows stay on one line and use ellipsis for unusually long labels. Drag the thin handle between the navigation rail and content pane to widen or narrow the rail. Fusion remembers that width in browser storage and restores it for both the standalone Settings modal and embedded Settings page. Mobile keeps the stacked **Settings Section** picker and does not show the resize handle. +On desktop and tablet, the Settings navigation rail has no hard divider between navigation and content; section rows stay on one line and use ellipsis for unusually long labels. Drag the thin handle between the navigation rail and content pane to widen or narrow the rail. Fusion remembers that width in browser storage and restores it for both the standalone Settings modal and embedded Settings page. Mobile keeps the stacked **Settings Section** picker and does not show the resize handle. Its native menu groups sections by the same topic headers as the desktop rail; paired scoped entries keep **Global** immediately before **Project**. 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. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index b401b60ab0..fc3fd3cd2b 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -4333,12 +4333,33 @@ export function SettingsModal({ value={activeSection} onChange={(event) => setActiveSection(event.target.value as SectionId)} > - {searchableSectionOptions.map((section) => { - const label = t(section.labelKey, section.label); + {/* + FNXC:SettingsNavigation 2026-07-16-14:00: + FN-8236 makes the mobile picker mirror the desktop topic headers with native + optgroups. Sections retain SETTINGS_SECTIONS order within each group, which + keeps scoped Global entries immediately before their Project counterparts so + mobile operators can relate inherited settings without a separate sidebar. + */} + {searchMatchedSections.map((groupHeader, groupIndex) => { + if (!groupHeader.isGroupHeader) return null; + + const sections = searchMatchedSections.slice(groupIndex + 1).filter((section) => !section.isGroupHeader); + const nextGroupIndex = searchMatchedSections.slice(groupIndex + 1).findIndex((section) => section.isGroupHeader); + const groupSections = nextGroupIndex === -1 ? sections : sections.slice(0, nextGroupIndex); + + if (groupSections.length === 0) return null; + return ( - + + {groupSections.map((section) => { + const label = t(section.labelKey, section.label); + return ( + + ); + })} + ); })} diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index f197d10c2a..ab14ac6df4 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -971,4 +971,84 @@ describe("SettingsModal mobile adaptations", () => { expectBaseRule(css, ".settings-search-toggle", "display: none;"); }); }); + + describe("mobile section picker groups (FN-8236)", () => { + it("mirrors desktop groups, omits advanced-only entries until enabled, and keeps scoped pairs global first", async () => { + mockSettingsViewport(true); + localStorage.setItem("fusion:settings:show-advanced", "false"); + const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 }); + const { getByLabelText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + const select = getByLabelText("Settings Section") as HTMLSelectElement; + const groupLabels = Array.from(select.querySelectorAll("optgroup")).map((group) => group.label); + expect(groupLabels).toEqual(["Preferences", "Project", "AI & Models", "Automation", "Integrations", "Infrastructure"]); + expect(Array.from(select.querySelectorAll("optgroup")).every((group) => group.querySelectorAll("option").length > 0)).toBe(true); + expect(Array.from(select.options).map((option) => option.text)).not.toContain("MCP Servers · Global"); + + const scopedPairs = [ + ["global-models", "project-models"], + ["research-global", "research-project"], + ["scheduling-global", "scheduling"], + ["source-control-global", "source-control"], + ["backups-global", "backups"], + ]; + const optionIds = Array.from(select.options).map((option) => option.value); + const visibleScopedPairs = scopedPairs.filter(([globalId, projectId]) => optionIds.includes(globalId) && optionIds.includes(projectId)); + expect(visibleScopedPairs).toEqual(expect.arrayContaining([ + ["global-models", "project-models"], + ["scheduling-global", "scheduling"], + ["source-control-global", "source-control"], + ])); + for (const [globalId, projectId] of visibleScopedPairs) { + expect(optionIds.indexOf(globalId)).toBe(optionIds.indexOf(projectId) - 1); + } + + await user.click(getByLabelText("Advanced settings")); + await waitFor(() => expect(Array.from(select.options).map((option) => option.text)).toContain("MCP Servers · Global")); + const advancedGroups = Array.from(select.querySelectorAll("optgroup")).map((group) => group.label); + expect(advancedGroups).toContain("Advanced"); + + const advancedOptionIds = Array.from(select.options).map((option) => option.value); + for (const [globalId, projectId] of [ + ["global-mcp", "mcp"], + ["research-global", "research-project"], + ]) { + const globalIndex = advancedOptionIds.indexOf(globalId); + const projectIndex = advancedOptionIds.indexOf(projectId); + if (globalIndex >= 0 && projectIndex >= 0) { + expect(globalIndex).toBe(projectIndex - 1); + } + } + }); + + it("keeps only matching non-empty groups during search and replaces the picker with the empty hint for no matches", async () => { + mockSettingsViewport(true); + const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 }); + const { container, getByLabelText, getByTestId, findByText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + await user.click(getByLabelText("Show search")); + await user.type(getByTestId("settings-search-input"), "mcp"); + + const select = getByLabelText("Settings Section") as HTMLSelectElement; + expect(Array.from(select.querySelectorAll("optgroup")).map((group) => group.label)).toEqual(["Integrations"]); + expect(Array.from(select.options).map((option) => option.text)).toEqual(["MCP Servers · Global", "MCP Servers · Project"]); + + await user.clear(getByTestId("settings-search-input")); + await user.type(getByTestId("settings-search-input"), "zzzzzz-no-match"); + await findByText("No sections match this search."); + expect(container.querySelector("#settings-mobile-section")).toBeNull(); + expect(container.querySelector(".settings-mobile-section-picker optgroup")).toBeNull(); + }); + + it("leaves desktop navigation as the sidebar without a mobile picker", async () => { + mockSettingsViewport(false); + const { container } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + expect(container.querySelector(".settings-sidebar")).toBeTruthy(); + expect(container.querySelector(".settings-mobile-section-picker")).toBeNull(); + }); + }); });