Settings had three compounding problems: you couldn't find a setting, and once you found it, it didn't look like its neighbour. ## Organization — the nav was grouped by the wrong thing It was grouped by **scope** (Global / Runtimes / Project). Scope is an *attribute* of a setting, not a category — nobody opens Settings thinking "I need a project-authority setting." That single choice split five concepts across two groups, including **two nav entries both labelled "MCP Servers"**, distinguishable only by a small icon. Now grouped by topic, with paired sections adjacent and stating their own scope:  ``` PREFERENCES Appearance · Keyboard Shortcuts · Notifications · General · Global PROJECT General · Project · Commands & Scripts · Worktrees · Merge AI & MODELS Models · Global · Models · Project · CLI Agents · Agents & Permissions · Prompts · Memory AUTOMATION Scheduling & Capacity · Scheduled Evals INTEGRATIONS Authentication · MCP Servers · Global · MCP Servers · Project · Plugins · Runtimes · Secrets INFRASTRUCTURE Node Sync · Node Routing · Remote Access · Backups ADVANCED Experimental Features ``` ## Search — it now finds settings, not just sections Search matched only hand-written `searchableText` keyword arrays per nav entry, and those demonstrably rotted: Project Models accumulated 20 keywords across **two separate fixes** (FN-7907, then title-summarization on 2026-07-14) because operators searched "summarize" and got nothing. Every setting is now indexed from **its real label and help text** — 123 settings across 18 sections, zero curated keywords. The same query that was patched twice by hand:  Two of those four hits match on *help text*, which a keyword index structurally cannot do. Picking a result jumps to the exact control, **opens its collapsed disclosure**, and highlights it:  **This can't rot again.** `settings-search-index.test.ts` extracts every `descriptor={{ key }}` from section sources and fails the build if one isn't indexed. "All settings are searchable" is an enforced invariant, not a promise. ## Consistency — one type scale A text field's label and a toggle's label in the same section were styled **three different ways**: `.form-group label` (12px/600/uppercase/muted), a `.settings-content` override narrowing it to 0.72rem, and `.checkbox-label` (13px/500/sentence-case) whose every declaration carried `!important` purely to out-specify the other two. | | Before | After | |---|---|---| | Sections on shared primitives | 1 | **18** | | Indexed searchable settings | 0 | **123** | | `form-group` in settings | 226 | **98** | | `checkbox-label` in settings | 79 | **40** |         **Global `.form-group` is untouched** — 35 non-settings files style forms with it, so settings migrate *off* it rather than restyle it underneath the rest of the dashboard. ### A real latent bug this surfaced `--font-size-sm` and `--font-size-md` were named by **12 declarations** across AgentDetailView, DockTaskList, SetupWizardModal, and ModelOnboardingModal — but **defined nowhere**. Those rules silently no-op'd and inherited. The command-center token guard is the only thing that catches this class of omission and it doesn't cover those files. The scale is now complete (`2xs · xs · sm · base · md · lg`); `md` is 1.125rem to hold current rendering, since unstyled `h3` already inherits the 1.17em UA default. ## What deliberately did *not* migrate Some rows stay bespoke **on purpose**, not from incompleteness: - **Password fields** (`ntfyAccessToken`, `githubAuthToken`, `gitlabAuthToken`) — `SettingsTextRow` hardcodes `type="text"`, so migrating would have **rendered stored tokens unmasked**. - **Help text with embedded `<code>`/links** — `descriptor.help` is a single string; flattening would drop markup or reword copy. - **`<details>` progressive disclosure** in Merge — a descriptor's help renders unconditionally, so migrating ~10 rows would delete the disclosure and produce a wall of prose. - **Dynamic flag lists** (Experimental) and bespoke editors/CRUD (Prompts, Agents & Permissions, Plugins, KeyboardShortcuts' capture widget) — no settings field name and no i18n key to anchor honestly. ## Data-model ambiguities surfaced (not papered over) These need a human call and are **not** fixed here: - **Five keys are declared in BOTH `DEFAULT_GLOBAL_SETTINGS` and `DEFAULT_PROJECT_SETTINGS`**: `worktrunk`, `testMode`, `gitlabEnabled`, `gitlabAuthToken`, `gitlabAuthTokenType`. No scope badge can be stamped honestly, so those rows are left bespoke. - **`globalMaxConcurrent` lives in the *project* blob** despite its name, its dedicated global endpoint, and the "Global" header it renders under. Its badge is omitted rather than assert a contradiction. - **Two settings were editable from two screens**: `gitlabEnabled` (General + Merge) and `githubTrackingDefaultRepo` (General + Global General). Both are ambiguous-scope and custom widgets, so deduplication is left as follow-up. ## Follow-ups from review **`SettingsTextRow` gained `type`, so the token rows could migrate.** It hardcoded `type="text"`, which is why every secret-bearing row (ntfy access token, GitHub/GitLab tokens, the Cloudflare tunnel token) stayed hand-rolled — migrating would have rendered stored secrets in plain text. `password` rows now default to `autocomplete="off"` so a browser never offers to save an API token, and masking is pinned by tests: a regression there would not throw and would not look wrong in review, the field would simply render the token. That also made them findable. Searching "token" previously matched nothing useful; it now returns 9 settings including *Access token* and *Tunnel token*: **Jump-to-field now reveals collapsed disclosures.** Rows inside a closed `<details>` are in the DOM but invisible, so the jump scrolled to and highlighted a control the operator could not see. The settings most worth searching for are exactly the ones behind "Advanced". **The scope banner is gone.** It claimed one scope for a whole section, which was false wherever a section mixed them — Appearance is a "global" nav entry whose task-presentation toggles are all project-scoped. Per-row badges already say this accurately, so the banner and its dead CSS are removed. **Scheduling is split by scope.** `globalMaxConcurrent` moved to its own `Scheduling · Global` section instead of sitting above the project settings behind an in-section subheading. One section held two authority levels, so "does this affect my other projects?" depended on which subheading you had scrolled past — and a search result landing mid-section shows no subheading at all.  **Text-entry padding is now genuinely uniform.** Settings shipped two input treatments: `.input`/`.select` (6px 10px at 13px) and the global `.form-group input` rule (8px 12px at 14px). Padding depended on whether an ancestor happened to be a `.form-group`, and because `.form-group input` (0,1,1) out-specifies `.input` (0,1,0), naming the standard class on a nested control did nothing. Measured in-browser after the fix: **51 controls across four sections, one appearance, zero outliers.** The same specificity trap was silently re-imposing the uppercase/muted label treatment on migrated rows nested in a `.form-group`; fixed as an invariant rather than per-row, since sections legitimately keep `.form-group` around bespoke content. ## Reconciling with #2147 (please review this call) PR #2147 landed while this branch was open and deliberately moved the two import auto-translate controls **off** `SettingsToggleRow` onto `checkbox-label`, pinning that markup with a test written to survive *"a refactor back onto the primitive"*. Its objection was that the primitive rendered a right-aligned toggle switch clashing with the section's native checkboxes — two idioms in one section. Both halves of that objection are now gone: the primitive renders a native checkbox **before** its label, and every checkbox in that section — including the neighbour the test asserts parity against — renders through it. The idiom split is resolved by migrating all of them rather than de-migrating these two, so the markup assertions now track the primitive. **Every behavioural contract from #2147 is kept and still pinned**, and one was a real bug on this branch: switching auto-translate off wrote `false` where it must write `undefined`, leaving an explicit opt-out in the settings blob instead of staying unset. #2147's curated translate keywords are preserved for the genuine vocabulary gaps ("localize", "localization", "foreign language issues") that appear in no copy. FN-8016's rewritten `taskPopupsBoardListOnly` copy (default now enabled) is adopted into the migrated row and its search entry. Worth noting: #2147's own FNXC says the translate controls were *"effectively unfindable"* because *"settings search only matches curated terms plus advertised i18n keys"* — 25 keywords hand-added days ago. That is precisely the rot this PR's derived index removes. ## Source Control — the duplicate had a cause GitLab settings were split across **three** sections (General: enable + URLs; Merge: auth token + type; Global General: all five at global scope) and GitHub across two. That split is *why* `gitlabEnabled` ended up writable from both General and Merge — two enable toggles for one key, last-save-wins. A `Source Control · Global` / `· Project` pair now owns all 17 keys, adjacent under Integrations, with **one** GitLab disclosure and **one** enable toggle:  Key ownership moved with them in `section-keys.ts` / `save-split.ts`, so every key has exactly one owner (`section-keys.test.ts` enforces disjointness). **A latent bug surfaced by the move:** nine sites outside the registries hardcode `"global-general"`/`"general"` and silently gate **scope routing** — four in `save-split.ts`, five in `SettingsModal.tsx`. Left stale, global GitLab edits would have been written as project overrides. Also verified against the schema: **all five `gitlab*` keys AND `githubTrackingDefaultRepo`** are declared in both defaults (more than the four originally identified), so those rows carry no scope badge rather than assert a scope the data model can't support. ## Help moved behind a "?" beside every label Rendering every description inline turned dense sections into walls of prose — median help is ~100 chars, some past 400. That pressure is what made Merge invent its own "More details" disclosure, so one section showed two idioms. Measured across sections, Merge's help was **not** unusually long (median 103 vs Appearance's 168, which rendered inline), so the disclosure wasn't earning its keep. The copy is deferred **visually, not removed**: the bubble is always rendered and only fades in, so it stays in the accessibility tree for `aria-describedby`, stays findable with in-page find, and **the search index keeps matching on help text**. Errors are never deferred — a validation message you must hunt for is one you won't see. Only ~24 of 136 `<small>` blocks were actually row help. The rest stay inline on purpose and aren't help: validation errors, live status, empty-state explanations, per-option descriptions inside a multi-select, and copy explaining *why* a control is disabled. `children: ReactNode` (not a string) is what let the `<code>`-bearing and link-bearing rows migrate without rewording your copy — a string API is precisely why they were hand-rolled before. ### Mobile, verified on a 390px viewport  Driving a real phone-sized viewport caught two bugs that neither jsdom nor code review did: - **Bubble rendered off-screen.** The label line reads "Name [scope] ?", so the "?" sits well right of centre; anchored to the trigger, the bubble spanned x=338→658 against a 390px screen — 268px unreachable. `max-width` clamps width but can't help when the *anchor* is near the edge. It now anchors to the row (`inset-inline: 0`): re-measured at x=20→370 inside 390. An earlier comment claimed this behaviour; only the comment existed. - **Two bubbles open at once.** Outside-`pointerdown` dismissal misses a path: `click` fires with **no pointer event** when Enter/Space activates a focused trigger, so a keyboard user opening a second tip left the first open underneath. Tips now broadcast on open and close each other; the regression test asserts the bare-click path specifically. Also verified on touch: tapping outside dismisses, and opening a second tip closes the first — no stranded bubbles. **Checkbox wrapping.** On a 390px viewport a long label ("Keep task popups on the view where they were opened") stranded its checkbox alone on line 1, with the text on lines 2-3 and the badge on line 4. The head was a flex row and the label was a flex ITEM, so once it no longer fit beside the checkbox the whole label wrapped rather than its text. Label + badge + tip now form one group that absorbs the wrapping, leaving the checkbox as the only sibling item; continuation lines align under the first word. Audited every checkbox and radio on all 31 screens at 390px afterwards: **101 controls, all on the first line of their label text.** (The audit's first pass flagged 41 — all false positives from measuring `<label>` elements whose text is a bare text node, i.e. the label box included the checkbox. Re-measuring the text nodes themselves via Range cleared them.) ### Every row, including the ones that stayed bespoke Merge was the last holdout — 18 `<details>` "More details" disclosures plus 4 inline blocks, an idiom it invented and no other section used. All 22 now use the same "?":  Project Models likewise rendered lane help as prose while the global lanes next door already used the tip; its help now hangs on the existing lane label row beside the Override/Inherited badge (the badge is live state and stays visible; the fallback chain behind it is what you open deliberately). **Audited all 31 screens programmatically** (label treatments, control padding, row overflow, leftover banners, horizontal scroll): - **one label treatment on every screen**, one control treatment, zero overflowing rows, zero banners, no horizontal scroll - the copy still rendered inline is deliberately not row help: validation errors, live status, block descriptions, per-option text inside multi-selects, and copy explaining *why* a control is disabled **Known gap:** the three plugin runtime screens (Hermes / OpenClaw / Paperclip) still render inline help. They delegate to `HermesRuntimeCard` / `OpenClawRuntimeCard` / `PaperclipRuntimeCard` — separate card components outside the settings tree — and their copy *is* genuine row help ("Leave blank to resolve hermes from your PATH"). They are advanced-only and were left out of this pass rather than swept in at the end without review. ## Padding and label consistency (measured, not eyeballed) Two idioms were still visible on one screen — Merge rendered "PLAN APPROVAL MODE" in caps directly above "Auto-merge conflict retries" in sentence case. Rows that deliberately stay bespoke inherited the global `.form-group label` treatment. | | Before | After | |---|---|---| | Label treatments | uppercase/muted/11.5px **and** sentence/14px | **70 labels, one treatment** | | Control padding | `6px 10px` **and** `8px 12px` | **81 controls, one treatment** | | Row gaps | 12 / 16 / 20 by adjacency | **0** — every row owns its space | The cause was a specificity trap: `.form-group input` (0,1,1) out-specifies `.input` (0,1,0), so naming the standard class on a nested control did nothing. Fixed settings-scoped; the global `.form-group` is untouched (35 non-settings files depend on it, where uppercase is that context's convention). ## Advanced settings toggle — verified Confirmed in-browser after the regroup: **OFF → 15 sections / 5 groups; ON → 31 sections / 7 groups**, `data-show-advanced` flips, preference persists. The now-empty **Infrastructure and Advanced group headers are correctly hidden** when off — the case the regroup could have broken, since those groups contain only advanced sections. ## Pre-existing failures found (verified NOT caused by this PR) Each verified by running the identical file on the parent commit and diffing the **failure sets**, not just the counts: | Failure | Verified | |---|---| | `SettingsModal.scheduling-merge.test.tsx` — 55 failures | identical set before/after | | `SettingsModal.remote-notifications.test.tsx` — 16 failures | identical set before/after | | `settings-default-descriptions.test.tsx` — `sqliteMigrationNotice`, `postgresMigrationInboxMessageSentAt` | fails on clean tree | | i18n `parity.test.ts` — 12 violations (`settings.general.autoTranslate*`, `taskDetail.plan.*`) | 12 before, 12 after | **This PR adds zero new failures.** Fixed along the way: a stale `AppearanceSection` assertion testing copy FN-7945 deliberately rewrote (failing silently), the ungrammatical "1 matching sections", and `“` rendering literally on screen. ## Verification - `tsc --noEmit -p tsconfig.app.json` → **0 errors** (note: the default `tsconfig.json` only covers `src/` and does **not** typecheck `app/`) - `pnpm test:gate` → **63 passed** - Settings surface → 68 failures, every one a verified subset of the pre-existing baseline, diffed by failure SET not count (this branch incidentally fixes 4) - `pnpm test:gate` 63/63 · lint clean across 83 changed files · `tsc -p tsconfig.app.json` 0 errors - Rebased onto `main`: conflicts with #2147 and FN-8016 resolved - Driven in a real browser at 1440px and 390px: search, jump-to-field, help tips, advanced toggle, and every migrated section - Driven in a real browser: search, jump-to-field, highlight, and every migrated section 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Settings search now indexes individual settings controls, ranks matches, and navigates directly to the exact row with a temporary highlight. * **UI Improvements** * Migrated settings sections to shared row primitives (toggle/select/number/text/textarea) for consistent spacing and touch-friendly controls. * Updated typography to a complete tokenized type scale; added the “?” help tip and tokenized row highlight/error styling. * Navigation and search labels now show clear Global vs Project scope. * **Bug Fixes** * Improved search accuracy using label/help/keywords and fixed settings search counts/pluralization and MCP scope labels. * **Tests** * Added/updated checks to ensure the settings search index stays consistent with rendered rows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
551 lines
24 KiB
TypeScript
551 lines
24 KiB
TypeScript
/**
|
|
* Save-split logic for SettingsModal (U9 / KTD-10).
|
|
*
|
|
* The modal edits a single merged form that mixes global-scope and
|
|
* project-scope keys. On save it must split that form into two patches with
|
|
* strict scope separation and preserve three subtle semantics:
|
|
*
|
|
* 1. Global keys are routed via {@link isGlobalSettingsKey} to the global
|
|
* patch; project keys via {@link isProjectSettingsKey} to the project
|
|
* patch. (A key can be neither — server-only/UI-only fields are dropped.)
|
|
* 2. Global and project writes are changed-only. This prevents any Settings
|
|
* save from re-sending default global values that can overwrite unrelated
|
|
* user preferences such as notifications or onboarding state.
|
|
* 3. null-as-delete: an explicit clear (current value `undefined`, but the
|
|
* initial value was defined) is written as `null` so it survives
|
|
* `JSON.stringify` and tells the server to delete the key. Plain
|
|
* `undefined` is dropped.
|
|
* 4. changed-only project writes: an inherited/effective project value that
|
|
* the user never touched is NOT serialized as an explicit override —
|
|
* doing so would silently break inheritance for every project setting on
|
|
* every save. Only keys whose value differs from the initial project-scoped
|
|
* value are written.
|
|
*
|
|
* This module is pure (no React, no network) so the regression-critical split
|
|
* behavior is characterized in isolation; the modal shell calls it and performs
|
|
* the actual `updateGlobalSettings`/`updateSettings` writes.
|
|
*/
|
|
import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
|
import type { GlobalSettings, McpServersSettings, Settings } from "@fusion/core";
|
|
|
|
/**
|
|
* Project-scoped model-override keys whose overrides track inheritance
|
|
* explicitly (changed-only writes with null-as-delete in the project branch).
|
|
*
|
|
* The title-summarizer lane was restored to project settings in FN-5994, so it
|
|
* needs the same changed-only/null-as-delete handling as the project default
|
|
* lane overrides. Execution/planning/validator lanes still live on workflow
|
|
* settings and are filtered out before the project branch is reached.
|
|
*
|
|
* FNXC:Settings-ThinkingLevel 2026-07-10-12:10:
|
|
* The project-scoped title-summarizer fallback thinking companion must travel
|
|
* with its provider/model pair so clearing the inline selector serializes as
|
|
* null-as-delete instead of being dropped as an unchanged inherited value.
|
|
*
|
|
* FNXC:Settings-MergerModel 2026-07-13-07:52:
|
|
* Merger project lane (provider/model/thinking) is project-scoped like
|
|
* title summarizer — not workflow-moved — so it participates in the same
|
|
* changed-only/null-as-delete project-branch write path.
|
|
*
|
|
* FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
|
* The import auto-translation settings are PROJECT-scoped: which language a repo's
|
|
* issues get translated into, and whether to translate at all, is a per-project
|
|
* decision, so they must route to the project patch and inherit when untouched.
|
|
* The lane's provider/model/thinking trio travels with the two non-model keys
|
|
* (`githubImportAutoTranslate`, `importTranslateTargetLocale`) so that clearing the
|
|
* toggle or the target locale serializes as null-as-delete (restoring inheritance)
|
|
* instead of being dropped as an unchanged inherited value. The companion
|
|
* `importTranslateGlobal*` keys are deliberately NOT listed here — they are global
|
|
* scope and are gated by GLOBAL_SECTION_KEYS below.
|
|
*/
|
|
export const MODEL_LANE_KEYS = [
|
|
"defaultProviderOverride", "defaultModelIdOverride",
|
|
"titleSummarizerProvider", "titleSummarizerModelId",
|
|
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel",
|
|
"mergerProvider", "mergerModelId", "mergerThinkingLevel",
|
|
"githubImportAutoTranslate", "importTranslateTargetLocale",
|
|
"importTranslateProvider", "importTranslateModelId", "importTranslateThinkingLevel",
|
|
] as const;
|
|
|
|
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
|
|
|
|
/*
|
|
FNXC:GitLabEnablement 2026-07-04-00:00:
|
|
FN-7535: the five global GitLab keys must be diffed against the SCOPED global
|
|
initial only — never the merged, project-effective `initialValues` — because
|
|
SettingsModal already edits these keys through a dedicated `globalGitlabSettings`
|
|
state seeded from `scoped.global` (FN-7453). Falling back to merged initialValues
|
|
when the scoped global object lacks the key (e.g. the operator has never saved a
|
|
global value before) let a project override's effective value silently stand in
|
|
for "no change", so a genuine global edit that happened to match the merged value
|
|
was dropped from the global patch. These keys never fall back to `initialValues`.
|
|
*/
|
|
const GLOBAL_GITLAB_SCOPED_ONLY_KEYS = new Set<string>([
|
|
"gitlabEnabled",
|
|
"gitlabInstanceUrl",
|
|
"gitlabApiBaseUrl",
|
|
"gitlabAuthToken",
|
|
"gitlabAuthTokenType",
|
|
]);
|
|
|
|
type RemoteAccessProvider = "tailscale" | "cloudflare";
|
|
type RemoteAccessPatch = NonNullable<GlobalSettings["remoteAccess"]>;
|
|
|
|
/*
|
|
FNXC:SettingsReset 2026-07-04-00:00:
|
|
Exported (not just module-private) so the FN-7506 section-keys registry
|
|
(settings/section-keys.ts) can reuse this as the single source of truth for
|
|
which GLOBAL keys belong to which settings section, instead of duplicating
|
|
the list for the "Reset this menu" feature.
|
|
*/
|
|
export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
|
appearance: new Set([
|
|
"themeMode",
|
|
"colorTheme",
|
|
"dashboardFontScalePct",
|
|
"shadcnCustomColors",
|
|
]),
|
|
notifications: new Set([
|
|
"ntfyEnabled",
|
|
"ntfyTopic",
|
|
"ntfyBaseUrl",
|
|
"ntfyAccessToken",
|
|
"ntfyEvents",
|
|
"ntfyDashboardHost",
|
|
"failureNotificationDelayMs",
|
|
"failureNotificationMode",
|
|
"webhookEnabled",
|
|
"webhookUrl",
|
|
"webhookFormat",
|
|
"webhookEvents",
|
|
"notificationProviders",
|
|
]),
|
|
experimental: new Set(["experimentalFeatures"]),
|
|
/*
|
|
FNXC:SourceControl 2026-07-15-20:30:
|
|
The global GitLab fallbacks and the global default tracking repo moved out of "global-general" into their own "source-control-global" section, paired with the project "source-control" section under the Integrations nav group.
|
|
This set is not just reset bookkeeping: `isGlobalKeyAllowedForSection` gates the SAVE path on it, so these keys reach the global patch only while their owning section is active.
|
|
*/
|
|
"source-control-global": new Set([
|
|
"githubTrackingDefaultRepo",
|
|
"gitlabEnabled",
|
|
"gitlabInstanceUrl",
|
|
"gitlabApiBaseUrl",
|
|
"gitlabAuthToken",
|
|
"gitlabAuthTokenType",
|
|
]),
|
|
"global-general": new Set([
|
|
"language",
|
|
"dismissModalsOnOutsideClick",
|
|
"persistAgentToolOutput",
|
|
"persistAgentThinkingLogPermanent",
|
|
"persistAgentThinkingLogEphemeral",
|
|
"fnBinaryCheckEnabled",
|
|
"updateCheckEnabled",
|
|
"updateCheckFrequency",
|
|
"autoReloadOnVersionChange",
|
|
]),
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
FN-7553 moves `dashboardKeyboardShortcuts` ownership out of "global-general" into its own dedicated section so the new Keyboard Shortcuts settings section (not General) owns save/reset for this key.
|
|
*/
|
|
"keyboard-shortcuts": new Set(["dashboardKeyboardShortcuts"]),
|
|
"global-mcp": new Set(["mcpServers"]),
|
|
"global-models": new Set([
|
|
"defaultProvider",
|
|
"defaultModelId",
|
|
"fallbackProvider",
|
|
"fallbackModelId",
|
|
"fallbackThinkingLevel",
|
|
"defaultThinkingLevel",
|
|
"modelRouterEnabled",
|
|
"modelRouterCheapProvider",
|
|
"modelRouterCheapModelId",
|
|
"opencodeGoModelSync",
|
|
"openrouterAppAttribution",
|
|
"openrouterModelFilters",
|
|
"openrouterModelSync",
|
|
"openrouterProviderPreferences",
|
|
"executionGlobalProvider",
|
|
"executionGlobalModelId",
|
|
"planningGlobalProvider",
|
|
"planningGlobalModelId",
|
|
"validatorGlobalProvider",
|
|
"validatorGlobalModelId",
|
|
"titleSummarizerGlobalProvider",
|
|
"titleSummarizerGlobalModelId",
|
|
"mergerGlobalProvider",
|
|
"mergerGlobalModelId",
|
|
"mergerGlobalThinkingLevel",
|
|
/*
|
|
FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
|
The import-translate GLOBAL lane keys must be section-allowlisted in both Models
|
|
sections (mirroring merger), otherwise the section gate in the global branch of
|
|
splitSettingsSave silently drops an operator's global lane edit on Save.
|
|
*/
|
|
"importTranslateGlobalProvider",
|
|
"importTranslateGlobalModelId",
|
|
"importTranslateGlobalThinkingLevel",
|
|
]),
|
|
"project-models": new Set([
|
|
"defaultProvider",
|
|
"defaultModelId",
|
|
"fallbackProvider",
|
|
"fallbackModelId",
|
|
"fallbackThinkingLevel",
|
|
"defaultThinkingLevel",
|
|
"modelRouterEnabled",
|
|
"modelRouterCheapProvider",
|
|
"modelRouterCheapModelId",
|
|
"opencodeGoModelSync",
|
|
"openrouterAppAttribution",
|
|
"openrouterModelFilters",
|
|
"openrouterModelSync",
|
|
"openrouterProviderPreferences",
|
|
"executionGlobalProvider",
|
|
"executionGlobalModelId",
|
|
"planningGlobalProvider",
|
|
"planningGlobalModelId",
|
|
"validatorGlobalProvider",
|
|
"validatorGlobalModelId",
|
|
"titleSummarizerGlobalProvider",
|
|
"titleSummarizerGlobalModelId",
|
|
"mergerGlobalProvider",
|
|
"mergerGlobalModelId",
|
|
"mergerGlobalThinkingLevel",
|
|
"importTranslateGlobalProvider",
|
|
"importTranslateGlobalModelId",
|
|
"importTranslateGlobalThinkingLevel",
|
|
]),
|
|
"node-sync": new Set([
|
|
"settingsSyncEnabled",
|
|
"settingsSyncAuth",
|
|
"settingsSyncInterval",
|
|
"settingsSyncConflictResolution",
|
|
]),
|
|
"research-global": new Set([
|
|
"researchGlobalDefaults",
|
|
"researchGlobalEnabled",
|
|
"researchGlobalMaxConcurrentRuns",
|
|
"researchGlobalDefaultTimeout",
|
|
"researchGlobalMaxSourcesPerRun",
|
|
"researchGlobalMaxSynthesisRounds",
|
|
"researchGlobalWebSearchProvider",
|
|
"researchGlobalSearxngUrl",
|
|
"researchGlobalBraveApiKey",
|
|
"researchGlobalGoogleSearchApiKey",
|
|
"researchGlobalGoogleSearchCx",
|
|
"researchGlobalTavilyApiKey",
|
|
"researchGlobalGitHubEnabled",
|
|
"researchGlobalLocalDocsEnabled",
|
|
"researchGlobalMaxSearchResults",
|
|
"researchGlobalFetchTimeoutMs",
|
|
"researchGlobalUserAgent",
|
|
]),
|
|
remote: new Set(["remoteAccess"]),
|
|
};
|
|
|
|
function isGlobalKeyAllowedForSection(key: string, activeSection: string): boolean {
|
|
const sectionKeys = GLOBAL_SECTION_KEYS[activeSection];
|
|
return !sectionKeys || sectionKeys.has(key);
|
|
}
|
|
|
|
export interface SaveSplitInput {
|
|
/** The fully-normalized form payload (after trimming/normalization). */
|
|
payload: Record<string, unknown>;
|
|
/** Initial merged settings, used to detect explicit clears of global keys. */
|
|
initialValues: Settings | null;
|
|
/** Initial scoped values, used to detect changed/cleared project overrides. */
|
|
initialScopedValues: { global: GlobalSettings; project: Partial<Settings> } | null;
|
|
/** The active section id; gates where section-owned values are written. */
|
|
activeSection: string;
|
|
/** Current raw MCP values for both scopes, preserved even after section navigation. */
|
|
scopedMcpValues?: { global: McpServersSettings | undefined; project: McpServersSettings | undefined };
|
|
}
|
|
|
|
export interface SaveSplitResult {
|
|
globalPatch: Partial<GlobalSettings>;
|
|
projectPatch: Partial<Settings>;
|
|
}
|
|
|
|
export type McpSettingsScope = "global" | "project";
|
|
export type ScopedSettingsValues = { global: GlobalSettings; project: Partial<Settings> };
|
|
|
|
/**
|
|
* Return the raw MCP value owned by one settings scope.
|
|
*
|
|
* FNXC:McpSettingsScopes 2026-07-14-21:59:
|
|
* SettingsModal's general form is project-effective, so MCP editing and saving must use the raw values returned by `/api/settings/scopes`. Preserve `undefined` for an absent project override: normalizing it to `{ enabled: false, servers: [] }` would replace global inheritance with an explicit disabled project setting on a no-op save.
|
|
*/
|
|
export function resolveScopedMcpSettings(
|
|
scope: McpSettingsScope,
|
|
scopedSettings: ScopedSettingsValues | null,
|
|
): McpServersSettings | undefined {
|
|
return scope === "global"
|
|
? scopedSettings?.global.mcpServers
|
|
: scopedSettings?.project.mcpServers;
|
|
}
|
|
|
|
function hasOwn(obj: object | null | undefined, key: string): boolean {
|
|
return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function settingsValueEquals(left: unknown, right: unknown): boolean {
|
|
if (Object.is(left, right)) return true;
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
if (left.length !== right.length) return false;
|
|
return left.every((item, index) => settingsValueEquals(item, right[index]));
|
|
}
|
|
if (isPlainObject(left) || isPlainObject(right)) {
|
|
if (!isPlainObject(left) || !isPlainObject(right)) return false;
|
|
const leftKeys = Object.keys(left);
|
|
const rightKeys = Object.keys(right);
|
|
if (leftKeys.length !== rightKeys.length) return false;
|
|
return leftKeys.every((key) => hasOwn(right, key) && settingsValueEquals(left[key], right[key]));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function readString(payload: Record<string, unknown>, key: string): string | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return String(payload[key] ?? "");
|
|
}
|
|
|
|
function readNullableString(payload: Record<string, unknown>, key: string): string | null | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return payload[key] ? String(payload[key]) : null;
|
|
}
|
|
|
|
function readBoolean(payload: Record<string, unknown>, key: string): boolean | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return Boolean(payload[key]);
|
|
}
|
|
|
|
function readNumber(payload: Record<string, unknown>, key: string, fallback: number): number | undefined {
|
|
if (!hasOwn(payload, key) || payload[key] === undefined) return undefined;
|
|
return Number(payload[key] ?? fallback);
|
|
}
|
|
|
|
function assignIfPresent<T extends object, K extends keyof T>(target: T, key: K, value: T[K] | undefined): void {
|
|
if (value !== undefined) {
|
|
target[key] = value;
|
|
}
|
|
}
|
|
|
|
function buildRemoteAccessPatch(payload: Record<string, unknown>): Partial<RemoteAccessPatch> | null {
|
|
const patch: Partial<RemoteAccessPatch> = {};
|
|
const activeProvider = hasOwn(payload, "remoteActiveProvider")
|
|
? (payload.remoteActiveProvider as RemoteAccessProvider | null)
|
|
: undefined;
|
|
|
|
if (activeProvider !== undefined) {
|
|
patch.activeProvider = activeProvider;
|
|
}
|
|
|
|
const tailscalePatch: Partial<RemoteAccessPatch["providers"]["tailscale"]> = {};
|
|
assignIfPresent(tailscalePatch, "enabled", readBoolean(payload, "remoteTailscaleEnabled"));
|
|
assignIfPresent(tailscalePatch, "hostname", readString(payload, "remoteTailscaleHostname"));
|
|
assignIfPresent(tailscalePatch, "targetPort", readNumber(payload, "remoteTailscaleTargetPort", 4040));
|
|
assignIfPresent(tailscalePatch, "acceptRoutes", readBoolean(payload, "remoteTailscaleAcceptRoutes"));
|
|
if (activeProvider === "tailscale") {
|
|
tailscalePatch.enabled = true;
|
|
}
|
|
|
|
const cloudflarePatch: Partial<RemoteAccessPatch["providers"]["cloudflare"]> = {};
|
|
assignIfPresent(cloudflarePatch, "enabled", readBoolean(payload, "remoteCloudflareEnabled"));
|
|
assignIfPresent(cloudflarePatch, "quickTunnel", readBoolean(payload, "remoteCloudflareQuickTunnel"));
|
|
assignIfPresent(cloudflarePatch, "tunnelName", readString(payload, "remoteCloudflareTunnelName"));
|
|
assignIfPresent(cloudflarePatch, "tunnelToken", readNullableString(payload, "remoteCloudflareTunnelToken"));
|
|
assignIfPresent(cloudflarePatch, "ingressUrl", readString(payload, "remoteCloudflareIngressUrl"));
|
|
if (activeProvider === "cloudflare") {
|
|
cloudflarePatch.enabled = true;
|
|
}
|
|
|
|
if (Object.keys(tailscalePatch).length > 0 || Object.keys(cloudflarePatch).length > 0) {
|
|
patch.providers = {} as RemoteAccessPatch["providers"];
|
|
if (Object.keys(tailscalePatch).length > 0) {
|
|
patch.providers.tailscale = tailscalePatch as RemoteAccessPatch["providers"]["tailscale"];
|
|
}
|
|
if (Object.keys(cloudflarePatch).length > 0) {
|
|
patch.providers.cloudflare = cloudflarePatch as RemoteAccessPatch["providers"]["cloudflare"];
|
|
}
|
|
}
|
|
|
|
const shortLivedPatch: Partial<RemoteAccessPatch["tokenStrategy"]["shortLived"]> = {};
|
|
assignIfPresent(shortLivedPatch, "enabled", readBoolean(payload, "remoteShortLivedEnabled"));
|
|
assignIfPresent(shortLivedPatch, "ttlMs", readNumber(payload, "remoteShortLivedTtlMs", 900_000));
|
|
assignIfPresent(shortLivedPatch, "maxTtlMs", readNumber(payload, "remoteShortLivedMaxTtlMs", 86_400_000));
|
|
if (Object.keys(shortLivedPatch).length > 0) {
|
|
patch.tokenStrategy = {
|
|
shortLived: shortLivedPatch as RemoteAccessPatch["tokenStrategy"]["shortLived"],
|
|
} as RemoteAccessPatch["tokenStrategy"];
|
|
}
|
|
|
|
const lifecyclePatch: Partial<RemoteAccessPatch["lifecycle"]> = {};
|
|
assignIfPresent(lifecyclePatch, "rememberLastRunning", readBoolean(payload, "remoteRememberLastRunning"));
|
|
assignIfPresent(lifecyclePatch, "wasRunningOnShutdown", readBoolean(payload, "remoteWasRunningOnShutdown"));
|
|
if (hasOwn(payload, "remoteLastStartedProvider") && payload.remoteLastStartedProvider !== undefined) {
|
|
lifecyclePatch.lastRunningProvider = payload.remoteLastStartedProvider as RemoteAccessProvider | null;
|
|
}
|
|
if (Object.keys(lifecyclePatch).length > 0) {
|
|
patch.lifecycle = lifecyclePatch as RemoteAccessPatch["lifecycle"];
|
|
}
|
|
|
|
return Object.keys(patch).length > 0 ? patch : null;
|
|
}
|
|
|
|
/**
|
|
* Split a normalized settings form payload into global and project patches,
|
|
* preserving null-as-delete and changed-only-project-write semantics.
|
|
*/
|
|
export function splitSettingsSave({
|
|
payload,
|
|
initialValues,
|
|
initialScopedValues,
|
|
activeSection,
|
|
scopedMcpValues,
|
|
}: SaveSplitInput): SaveSplitResult {
|
|
const globalPatch: Partial<GlobalSettings> = {};
|
|
|
|
if (activeSection === "remote") {
|
|
/*
|
|
FNXC:RemoteAccessSettings 2026-06-30-00:00:
|
|
Main Settings Save must persist the Remote Access section's flattened form fields into the canonical nested remoteAccess object. Windows users commonly configure Tailscale options and click Save without starting the tunnel, so this path cannot rely on the Start Tunnel auto-save.
|
|
*/
|
|
const remoteAccessPatch = buildRemoteAccessPatch(payload);
|
|
if (remoteAccessPatch) {
|
|
globalPatch.remoteAccess = remoteAccessPatch as RemoteAccessPatch;
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
/*
|
|
FNXC:SourceControl 2026-07-15-20:30:
|
|
These six keys are dual-scope (declared in both DEFAULT_GLOBAL_SETTINGS and DEFAULT_PROJECT_SETTINGS), so the ACTIVE SECTION — not the key — decides which patch they land in: the global fallbacks are editable only from "source-control-global", and every other section's copy of the key is the project override. The id moved with the controls (was "global-general"); it must track whichever section renders the global GitLab/tracking-repo rows, or a global edit would silently be written as a project override.
|
|
*/
|
|
if (key === "githubTrackingDefaultRepo" && activeSection !== "source-control-global") {
|
|
continue;
|
|
}
|
|
if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection !== "source-control-global") {
|
|
continue;
|
|
}
|
|
if (key === "mcpServers" && scopedMcpValues) {
|
|
continue;
|
|
}
|
|
if (key === "mcpServers" && activeSection !== "global-mcp") {
|
|
continue;
|
|
}
|
|
if (key === "persistAgentThinkingLog") {
|
|
continue;
|
|
}
|
|
// customProviders is a global key, but it is NOT written through the
|
|
// save-split form. It is persisted via its own REST routes
|
|
// (register-custom-provider-routes.ts -> store.updateGlobalSettings) which
|
|
// mask API keys on read (sanitizeProvider). Routing it through this patch
|
|
// would write the masked keys back and clobber the real credentials.
|
|
if (key === "customProviders") {
|
|
continue;
|
|
}
|
|
if (isGlobalSettingsKey(key)) {
|
|
/*
|
|
FNXC:SettingsPersistence 2026-06-23-00:55:
|
|
Global settings saves must be changed-only, just like project settings. The Settings form carries full default-shaped global values, so emitting unchanged globals can overwrite unrelated user preferences (notifications, onboarding state, theme) when a user saves another section or when experimental-feature normalization allocates a fresh but equivalent object.
|
|
|
|
FNXC:SettingsPersistence 2026-06-23-01:18:
|
|
Global Settings saves are also gated by the active settings section. The form can contain stale/default values from sections the user did not edit, so changed-only comparison alone cannot distinguish an intentional Appearance edit from a default-filled Notifications or onboarding field.
|
|
*/
|
|
if (!isGlobalKeyAllowedForSection(key, activeSection)) {
|
|
continue;
|
|
}
|
|
|
|
if (value === undefined && key === "ntfyAccessToken" && activeSection === "notifications") {
|
|
(globalPatch as Record<string, unknown>)[key] = null;
|
|
continue;
|
|
}
|
|
|
|
const scopedOnly = GLOBAL_GITLAB_SCOPED_ONLY_KEYS.has(key);
|
|
const hasScopedInitial = hasOwn(initialScopedValues?.global, key);
|
|
const hasMergedInitial = !scopedOnly && hasOwn(initialValues, key);
|
|
const initialValue = hasScopedInitial
|
|
? initialScopedValues?.global?.[key as keyof GlobalSettings]
|
|
: scopedOnly
|
|
? undefined
|
|
: initialValues?.[key as keyof GlobalSettings];
|
|
const hasInitialValue = hasScopedInitial || hasMergedInitial;
|
|
|
|
if (settingsValueEquals(value, initialValue)) {
|
|
continue;
|
|
}
|
|
|
|
// null-as-delete: explicit clear is sent as null, plain undefined dropped.
|
|
if (value === undefined && hasInitialValue && initialValue !== undefined) {
|
|
(globalPatch as Record<string, unknown>)[key] = null;
|
|
} else if (value !== undefined) {
|
|
(globalPatch as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const projectPatch: Partial<Settings> = {};
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only
|
|
if (key === "customProviders") continue; // persisted via dedicated routes, not save-split (see global branch above)
|
|
// Mirror of the global branch's dual-scope gate: while the global source-control
|
|
// section is active these six keys are the GLOBAL fallbacks, so they must not
|
|
// also be written as project overrides. See the FNXC note in the global branch.
|
|
if (key === "githubTrackingDefaultRepo" && activeSection === "source-control-global") continue;
|
|
if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection === "source-control-global") continue;
|
|
if (key === "mcpServers" && scopedMcpValues) continue;
|
|
if (key === "mcpServers" && activeSection === "global-mcp") continue;
|
|
if (!isProjectSettingsKey(key)) continue;
|
|
|
|
const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings];
|
|
|
|
if (MODEL_LANE_KEY_SET.has(key)) {
|
|
if (!settingsValueEquals(value, initialProjectValue)) {
|
|
if (
|
|
(value === undefined || value === null) &&
|
|
initialProjectValue !== undefined &&
|
|
initialProjectValue !== null
|
|
) {
|
|
(projectPatch as Record<string, unknown>)[key] = null;
|
|
} else if (value !== undefined) {
|
|
(projectPatch as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
} else {
|
|
// Changed-only gate + null-as-delete for non-model project settings.
|
|
if (!settingsValueEquals(value, initialProjectValue)) {
|
|
if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) {
|
|
(projectPatch as Record<string, unknown>)[key] = null;
|
|
} else if (value !== undefined) {
|
|
(projectPatch as Record<string, unknown>)[key] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:McpSettingsScopes 2026-07-14-22:10:
|
|
Saving must not discard scoped MCP edits merely because the initial scoped snapshot is unavailable. Compare against an undefined baseline in that case so the current raw scoped values are still persisted.
|
|
*/
|
|
if (scopedMcpValues) {
|
|
const initialGlobalMcp = resolveScopedMcpSettings("global", initialScopedValues);
|
|
if (!settingsValueEquals(scopedMcpValues.global, initialGlobalMcp)) {
|
|
(globalPatch as Record<string, unknown>).mcpServers = scopedMcpValues.global ?? null;
|
|
}
|
|
|
|
const initialProjectMcp = resolveScopedMcpSettings("project", initialScopedValues);
|
|
if (!settingsValueEquals(scopedMcpValues.project, initialProjectMcp)) {
|
|
(projectPatch as Record<string, unknown>).mcpServers = scopedMcpValues.project ?? null;
|
|
}
|
|
}
|
|
|
|
return { globalPatch, projectPatch };
|
|
}
|