diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index f7a2fdb4e2..4a74ab5d4c 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react"; -import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react"; +import { Globe, Folder, GitBranch, RefreshCw, Star, HelpCircle, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react"; import { getErrorMessage, resolveGitlabConfig, @@ -46,6 +46,7 @@ import { GeneralSection } from "./settings/sections/GeneralSection"; import { ProjectModelsSection, WorkflowLaneFlushRejection } from "./settings/sections/ProjectModelsSection"; import { SchedulingSection } from "./settings/sections/SchedulingSection"; import { SchedulingGlobalSection } from "./settings/sections/SchedulingGlobalSection"; +import { CliBinarySection } from "./settings/sections/CliBinarySection"; import { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection"; import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection"; import { WorktreesSection } from "./settings/sections/WorktreesSection"; @@ -293,6 +294,7 @@ const ADVANCED_SETTINGS_SECTION_IDS = new Set([ "mcp", "prompts", "plugins", + "cli-binary", ]); function readAdvancedSettingsPreference(): boolean { @@ -455,6 +457,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project", searchableText: ["auto merge", "AI merge", "merge strategy", "plan approval", "direct merge", "integration branch", "push after merge"] }, { id: "__ai_header", label: "AI & Models", labelKey: "settings.nav.aiHeader", scope: undefined, isGroupHeader: true }, + /* + FNXC:SettingsNavigation 2026-07-16-01:30: + Authentication leads the AI & Models group, and Settings still opens on it. + It is a provider-credentials screen, so it belongs with the model settings it gates rather than under Integrations (where it sat among MCP/Plugins/runtimes) or floating above the groups as a special case — connecting a provider and choosing its models are one task, done in that order. + First within the group because nothing else in AI & Models can be configured until it is done: with no provider connected there are no models to pick. + */ + { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] }, { id: "global-models", label: "Models · Global", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing", "translate", "translation model", "import translation model", "import auto-translation model"] }, /** * FNXC:SettingsNavigation 2026-07-13-00:00: @@ -577,9 +586,8 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ The two are adjacent and ordered global-then-project to match the inheritance they model — the global entry holds the fallbacks the project entry overrides — mirroring the MCP Servers pair directly below. The GitLab/GitHub keywords below were curated on the `general` and `merge` nav entries before their controls moved here; a keyword left behind would send an operator searching "gitlab token" to a section that no longer renders one. The translate keywords deliberately did NOT move: `githubImportAutoTranslate`/`importTranslateTargetLocale` are Import Tasks panel settings and stay in General. */ - { id: "source-control-global", label: "Source Control · Global", labelKey: "settings.nav.sourceControlGlobal", scope: "global", searchableText: ["GitLab instance URL", "global tracking repo", "GitLab", "GitHub", "global GitLab token", "GitLab fallback", "source control", "forge"] }, - { id: "source-control", label: "Source Control · Project", labelKey: "settings.nav.sourceControl", scope: "project", searchableText: ["GitHub tracking", "GitLab integration", "GitHub auth mode", "GitLab access token", "GitHub personal access token", "tracking repo", "source control", "forge", "gh cli", "issue tracking"] }, - { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] }, + { id: "source-control-global", label: "Source Control · Global", labelKey: "settings.nav.sourceControlGlobal", scope: "global", icon: GitBranch, searchableText: ["GitLab instance URL", "global tracking repo", "GitLab", "GitHub", "global GitLab token", "GitLab fallback", "source control", "forge"] }, + { id: "source-control", label: "Source Control · Project", labelKey: "settings.nav.sourceControl", scope: "project", icon: GitBranch, searchableText: ["GitHub tracking", "GitLab integration", "GitHub auth mode", "GitLab access token", "GitHub personal access token", "tracking repo", "source control", "forge", "gh cli", "issue tracking"] }, { id: "global-mcp", label: "MCP Servers · Global", labelKey: "settings.nav.globalMcp", scope: "global", searchableText: ["global MCP servers", "shared MCP", "user MCP", "tool servers"] }, { id: "mcp", label: "MCP Servers · Project", labelKey: "settings.nav.mcp", scope: "project", searchableText: ["project MCP servers", "workspace MCP", "project tool servers", "mcp config"] }, { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project", searchableText: ["Fusion plugins", "Pi extensions", "plugin manager", "extension marketplace"] }, @@ -600,6 +608,11 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "__advanced_header", label: "Advanced", labelKey: "settings.nav.advancedHeader", scope: undefined, isGroupHeader: true }, { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global", searchableText: ["feature flags", "experiments", "research view", "evals view", "sandbox", "subtask breakdown"] }, + /* + FNXC:SettingsNavigation 2026-07-16-01:00: + Last entry in the nav, and advanced-only. The `fn` binary panel used to render at the TOP of "General · Global", so machine plumbing was the first thing an operator saw on opening Settings. It is install/version/path maintenance touched once or when something breaks — the definition of what the Advanced switch hides. + */ + { id: "cli-binary", label: "CLI Binary", labelKey: "settings.nav.cliBinary", scope: "global", searchableText: ["fn binary", "cli", "install", "version", "path", "upgrade", "homebrew", "binary check"] }, ]; // FNXC:SettingsNavigation 2026-07-04-00:00: sectionId -> owning group label ("Global"/"Runtimes"/"Project"), @@ -730,7 +743,12 @@ function normalizeWorktreeCopyFilesForSave(paths?: string[]): string[] { type LegacySectionId = "pi-extensions"; export type SectionId = SettingsSection["id"] | LegacySectionId; -const DEFAULT_SETTINGS_SECTION: SectionId = "global-general"; +/* +FNXC:SettingsNavigation 2026-07-16-01:00: +Settings opens on Authentication. It is the section an operator actually needs first — nothing else in the product works until a provider is connected, and the dashboard's own empty state points here ("Set one up in Settings → AI Setup"). The previous landing section was "General · Global", a page of app preferences (and, until this change, the `fn` binary panel) that nobody opens Settings to find. +It is also always visible: Authentication is not behind the Advanced switch, so the default landing section cannot be one the operator has hidden. +*/ +const DEFAULT_SETTINGS_SECTION: SectionId = "authentication"; type PluginsSubsectionId = "fusion-plugins" | "pi-extensions"; @@ -1184,6 +1202,8 @@ export function SettingsModal({ The setting a search result asked to land on. Held here rather than in the sections because the modal owns both halves of the jump: it switches the active section AND scrolls to the row, and only the row itself knows how to flag the match (via SettingsSearchHighlightProvider). */ const [highlightedSettingKey, setHighlightedSettingKey] = useState(null); + /** Set when a search result drives the next section change; see the scroll-to-top effect. */ + const settingsJumpPendingRef = useRef(false); const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() => viewportMode === "mobile" || (typeof window !== "undefined" && typeof window.matchMedia === "function" @@ -1352,10 +1372,31 @@ export function SettingsModal({ Landing a search result is two steps that cannot happen in one: switching section unmounts the old one and mounts the target, so the row does not exist in the DOM until React commits. The click sets section + key, and the effect below scrolls once the row is actually there. */ const handleSettingsSearchResultSelect = useCallback((sectionId: string, key: string) => { + // Claims the upcoming section change so the scroll-to-top below yields to the + // jump; otherwise the row we just scrolled to would be scrolled away from. + settingsJumpPendingRef.current = true; setActiveSection(sectionId); setHighlightedSettingKey(key); }, []); + /* + FNXC:SettingsNavigation 2026-07-16-01:10: + Switching section starts you at the top of it. + Sections keep no scroll of their own, so the container's offset carried over: leaving a long section scrolled halfway (Scheduling, General) and picking a short one landed mid-content — on mobile, often past everything, on an apparently blank screen with no hint to scroll up. + Guarded by a ref rather than by reading `highlightedSettingKey`: a search jump also changes `activeSection`, and that key clears itself ~1.6s later, which would re-run this effect and yank the operator away from the row they had just jumped to. The ref is consumed once, so only the jump's own section change is exempt. + */ + useEffect(() => { + if (settingsJumpPendingRef.current) { + settingsJumpPendingRef.current = false; + return; + } + /* + FNXC:SettingsNavigation 2026-07-16-01:10: + Assigns `scrollTop` rather than calling `scrollTo({top:0})`: jsdom implements the property but not the method, so the smarter-looking call throws "scrollTo is not a function" in component tests. The jump is instant either way — a section change is a context switch, not a movement the eye should follow. + */ + if (settingsContentRef.current) settingsContentRef.current.scrollTop = 0; + }, [activeSection]); + useEffect(() => { if (!highlightedSettingKey) return; const container = settingsContentRef.current; @@ -3891,6 +3932,8 @@ export function SettingsModal({ researchLimitError={researchLimitError} /> ); + case "cli-binary": + return ; case "experimental": return ( - {section.scope === "global" && } - {section.scope === "project" && } - {section.icon && !section.scope && ( - + {/* + FNXC:SettingsNavigation 2026-07-16-01:30: + A section's own icon wins over the generic scope glyph; the glyph is the fallback. + Previously `icon` only rendered when `scope` was undefined, so any scoped section was forced to the globe/folder pair and could not identify itself — Source Control looked exactly like MCP Servers and Scheduling in the nav. + Nothing is lost by yielding the glyph: these sections spell their tier out in the label ("Source Control · Global"), and the aria-label + tooltip below still announce it, so scope survives for assistive tech and hover. + */} + {section.icon ? ( + + ) : ( + <> + {section.scope === "global" && } + {section.scope === "project" && } + )} {t(section.labelKey, section.label)} diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index ee18d662f0..cd2822488d 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -283,7 +283,12 @@ describe("SettingsModal", () => { expect(modal?.getAttribute("style")).toContain("--vv-height: 400px"); }); - it("defaults to the global General section when no initialSection is provided", async () => { + /* + FNXC:SettingsNavigation 2026-07-16-01:10: + Settings opens on Authentication, not "General · Global". Nothing else in the product works until a provider is connected — the dashboard's own empty state sends operators to Settings for exactly this — whereas the old landing section was app preferences nobody opens Settings to find. + Authentication is also not behind the Advanced switch, so the landing section can never be one the operator has hidden; that is asserted here rather than left implicit. + */ + it("defaults to the Authentication section when no initialSection is provided", async () => { render( { ); await waitForSettingsModalReady(); - expect(screen.getByRole("button", { name: /^General · Global$/ })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument(); + /* + FNXC:SettingsNavigation 2026-07-16-01:30: + Asserts the landing section is REACHABLE, not that it is first in the nav: Authentication leads the AI & Models group (it belongs with the model settings it gates), so nav position is a layout choice that may move again. + What must hold is that the section Settings opens on is never one the Advanced switch hides — otherwise a default-configured operator lands on a section their own nav does not list. + */ + expect(screen.getByRole("checkbox", { name: "Advanced settings" })).not.toBeChecked(); + expect(screen.getByRole("button", { name: /^Authentication$/ })).toBeInTheDocument(); }); /* diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx index e92f4f916c..0aa6487ade 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx @@ -1527,7 +1527,8 @@ describe("SettingsModal", () => { it("falls back to first visible section when initial section is unavailable", async () => { renderModal({ initialSection: "unknown-section" as any }); await waitForSettingsModalReady(); - expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); + // FNXC:SettingsNavigation 2026-07-16-01:10: an unknown section still falls back to the first visible one; that is Authentication now, which is also where Settings opens. + expect(await screen.findByRole("heading", { name: "Authentication" })).toBeInTheDocument(); }); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 1b3c84b3f8..128d88254f 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -1661,7 +1661,11 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); expect(screen.queryByRole("button", { name: /Research Defaults/i })).not.toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + /* + FNXC:SettingsNavigation 2026-07-16-01:10: + The fallback lands on Authentication, which is the first selectable section and where Settings opens. This asserted "General" back when that was the default landing section; the requirement is unchanged — an unreachable section falls back to the first selectable one — only which section that is has moved. + */ + expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument(); }); it("hides scheduled evals nav item when experimentalFeatures.evalsView is disabled", async () => { @@ -1698,7 +1702,8 @@ describe("SettingsModal", () => { await waitForSettingsModalReady(); expect(screen.queryByRole("button", { name: /Scheduled Evals/i })).not.toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + // FNXC:SettingsNavigation 2026-07-16-01:10: falls back to Authentication — now the first selectable section and where Settings opens. Same requirement, different landing section. + expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument(); }); }); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 2a615654c4..b0ca3c60bd 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -563,8 +563,14 @@ describe("SettingsModal mobile adaptations", () => { const { container, findAllByText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); - // Authentication is first by default, so click General to see form controls - const generalTabs = await findAllByText("General"); + /* + FNXC:SettingsNavigation 2026-07-16-01:10: + Settings opens on Authentication, which renders provider cards rather than form + controls, so this navigates to a section that has some. The nav label is + "General · Project" now that the Global/Project pair is disambiguated — plain + "General" no longer matches any element. + */ + const generalTabs = await findAllByText("General · Project"); await user.click(generalTabs[0]); const controls = container.querySelectorAll(".settings-content input, .settings-content select, .settings-content textarea"); diff --git a/packages/dashboard/app/components/settings/sections/CliBinarySection.tsx b/packages/dashboard/app/components/settings/sections/CliBinarySection.tsx new file mode 100644 index 0000000000..86fa2d77b3 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/CliBinarySection.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; +import { CliBinaryPanel } from "../../CliBinaryPanel"; + +/* +FNXC:SettingsNavigation 2026-07-16-01:00: +The `fn` CLI binary panel gets its own advanced-only section at the bottom of the nav instead of sitting at the TOP of "General · Global". +It was the first thing an operator saw when Settings opened — a binary install/version/path panel above the app preferences most people came for. It is machine-level plumbing an operator touches once (or when an install breaks), not a preference, so it belongs behind the Advanced switch with the other specialist surfaces rather than in the default-visible set. +Placed last, in the Advanced group, because "least often needed" is exactly the ordering that group encodes. +*/ +export function CliBinarySection() { + const { t } = useTranslation("app"); + return (<> +

{t("settings.nav.cliBinary", "CLI Binary")}

+ + ); +} +export default CliBinarySection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 2ed121cb9a..ff23a6cb1d 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -1,5 +1,4 @@ import { resolvePersistAgentThinkingLog } from "@fusion/core"; -import { CliBinaryPanel } from "../../CliBinaryPanel"; import { SettingsToggleRow } from "../SettingsToggleRow"; import { SettingsSelectRow } from "../SettingsSelectRow"; import type { SectionBaseProps } from "./context"; @@ -9,7 +8,7 @@ export type GlobalGeneralSectionProps = SectionBaseProps; FNXC:SettingsStyling 2026-07-15-17:35: Plain settings rows render through the shared primitives rather than hand-rolled `form-group` + `checkbox-label` markup, so labels, help copy, and padding come from one type scale. `.form-group` stays global and untouched — 35 non-settings files style forms with it. The migrated keys are all global-tier (DEFAULT_GLOBAL_SETTINGS), so each carries a "global" badge stating that it travels between projects. -Rows that stay bespoke are the ones whose copy a single-string descriptor cannot carry without rewording it: the `fn` binary check, the update-check toggle, and the thinking-log group all build label or help from `t()` fragments interleaved with `` tags. The thinking-log pair additionally shares ONE help string across two checkboxes, which no per-row descriptor models. CliBinaryPanel is a custom widget. +Rows that stay bespoke are the ones whose copy a single-string descriptor cannot carry without rewording it: the `fn` binary check, the update-check toggle, and the thinking-log group all build label or help from `t()` fragments interleaved with `` tags. The thinking-log pair additionally shares ONE help string across two checkboxes, which no per-row descriptor models. The CLI binary panel has moved to its own advanced-only section. */ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProps) { const { t } = useTranslation("app"); @@ -19,7 +18,10 @@ export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProp FNXC:SourceControl 2026-07-15-20:30: The global GitLab disclosure and the global default tracking repo moved to "Source Control · Global" (SourceControlGlobalSection.tsx), paired with the project source-control section under the Integrations nav group. They are forge integration settings, not general app preferences. */} - + {/* + FNXC:SettingsNavigation 2026-07-16-01:00: + The `fn` CLI binary panel moved OUT of this section to its own advanced-only "CLI Binary" section at the bottom of the nav. It used to render first here, so a binary install/version/path panel was the first thing an operator saw on opening Settings — machine plumbing above the preferences they came for. Do not move it back. + */}