Settings: one type scale, every setting searchable, nav grouped by topic (#2158)
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>
This commit is contained in:
7
.changeset/settings-ui-consistency-and-search.md
Normal file
7
.changeset/settings-ui-consistency-and-search.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Settings search now finds and jumps to individual settings, and settings screens share one type scale.
|
||||
category: feature
|
||||
dev: Sections render through the shared `settings/` row primitives (`SettingsToggleRow`/`SelectRow`/`NumberRow`/`TextRow`/`TextareaRow`) instead of hand-rolled `form-group`/`checkbox-label` markup; global `.form-group` is unchanged for the 35 non-settings files that use it. Search is indexed from per-section `<Name>Section.search.ts` entries aggregated in `settings/search/entries.ts`, replacing the hand-curated `searchableText` keyword arrays as the primary match path (keywords remain a fallback for unmigrated sections). `settings-search-index.test.ts` fails the build when a rendered descriptor `key` is missing from the index. Adds the missing `--font-size-sm`/`--font-size-md` tokens plus `2xs`/`lg`, which were referenced by 12 declarations but never defined.
|
||||
@@ -38,6 +38,127 @@ describe("SettingsFieldRow", () => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Required");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
Help is deferred behind a "?" beside the label, so these pin the parts that are easy to break silently:
|
||||
- the trigger exists and toggles (click is the ONLY interaction a touch device has — a hover-only tip is invisible on mobile, and this suite runs in jsdom where hover cannot be simulated anyway);
|
||||
- the copy stays in the DOM and reachable via `aria-describedby` while closed, because deferring it visually must not remove it from assistive tech, in-page find, or the settings search index;
|
||||
- the error band is NOT deferred.
|
||||
*/
|
||||
it("puts help behind a trigger that toggles on click", () => {
|
||||
render(
|
||||
<SettingsFieldRow htmlFor="theme" label="Theme" help="Pick a theme">
|
||||
<input aria-label="control" />
|
||||
</SettingsFieldRow>,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: "Show help" });
|
||||
const tip = trigger.closest(".settings-help")!;
|
||||
|
||||
expect(tip).toHaveAttribute("data-open", "false");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(tip).toHaveAttribute("data-open", "true");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(tip).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
it("keeps help copy in the accessibility tree while collapsed", () => {
|
||||
render(
|
||||
<SettingsFieldRow htmlFor="theme" label="Theme" help="Pick a theme">
|
||||
<input aria-label="control" />
|
||||
</SettingsFieldRow>,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: "Show help" });
|
||||
const describedBy = trigger.getAttribute("aria-describedby")!;
|
||||
const bubble = document.getElementById(describedBy);
|
||||
|
||||
// Present and readable even though the row is collapsed — not display:none.
|
||||
expect(bubble).not.toBeNull();
|
||||
expect(bubble).toHaveTextContent("Pick a theme");
|
||||
expect(screen.getByText("Pick a theme")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes an open tip on Escape", () => {
|
||||
render(
|
||||
<SettingsFieldRow htmlFor="theme" label="Theme" help="Pick a theme">
|
||||
<input aria-label="control" />
|
||||
</SettingsFieldRow>,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: "Show help" });
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.closest(".settings-help")).toHaveAttribute("data-open", "true");
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(trigger.closest(".settings-help")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
it("closes an open tip when pointing elsewhere, so a tap on mobile cannot strand it", () => {
|
||||
render(
|
||||
<SettingsFieldRow htmlFor="theme" label="Theme" help="Pick a theme">
|
||||
<input aria-label="control" />
|
||||
</SettingsFieldRow>,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: "Show help" });
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.closest(".settings-help")).toHaveAttribute("data-open", "true");
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(trigger.closest(".settings-help")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-22:25:
|
||||
Only one tip may be open. The outside-pointerdown handler is not sufficient on its own: `click` fires with NO pointer event when a keyboard operator presses Enter/Space on a focused trigger, so opening a second tip that way used to leave the first bubble open underneath it — observed as two overlapping bubbles on a phone-sized viewport.
|
||||
Asserted with a bare `click()` precisely because that is the no-pointerdown path.
|
||||
*/
|
||||
it("closes any other open tip when one opens, including without a pointer event", () => {
|
||||
render(
|
||||
<>
|
||||
<SettingsFieldRow htmlFor="alpha" label="Alpha" help="Alpha help">
|
||||
<input aria-label="a" />
|
||||
</SettingsFieldRow>
|
||||
<SettingsFieldRow htmlFor="beta" label="Beta" help="Beta help">
|
||||
<input aria-label="b" />
|
||||
</SettingsFieldRow>
|
||||
</>,
|
||||
);
|
||||
const [alphaBtn, betaBtn] = screen.getAllByRole("button", { name: "Show help" });
|
||||
const alpha = alphaBtn.closest(".settings-help")!;
|
||||
const beta = betaBtn.closest(".settings-help")!;
|
||||
|
||||
fireEvent.click(alphaBtn);
|
||||
expect(alpha).toHaveAttribute("data-open", "true");
|
||||
|
||||
// No pointerdown — the keyboard path.
|
||||
fireEvent.click(betaBtn);
|
||||
expect(beta).toHaveAttribute("data-open", "true");
|
||||
expect(alpha).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
it("renders no help trigger when a row has no help", () => {
|
||||
render(
|
||||
<SettingsFieldRow htmlFor="theme" label="Theme">
|
||||
<input aria-label="control" />
|
||||
</SettingsFieldRow>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Show help" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the error band inline rather than behind the help trigger", () => {
|
||||
render(
|
||||
<SettingsFieldRow htmlFor="theme" label="Theme" help="Pick a theme" error="Required">
|
||||
<input aria-label="control" />
|
||||
</SettingsFieldRow>,
|
||||
);
|
||||
// A validation message the operator must go looking for is one they will not see.
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveTextContent("Required");
|
||||
expect(alert.closest(".settings-help")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a scope badge when scope is set", () => {
|
||||
render(
|
||||
<SettingsFieldRow label="Theme" scope="global">
|
||||
@@ -193,6 +314,57 @@ describe("SettingsTextRow", () => {
|
||||
expect(typeof onChange.mock.calls[0][0]).toBe("string");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SettingsSecurity 2026-07-15-18:52:
|
||||
Masking is asserted, not assumed. This primitive hardcoded `type="text"`, which is why every token row (ntfy access token, GitHub/GitLab tokens, Cloudflare tunnel token) had to stay hand-rolled to avoid rendering a stored secret in plain text.
|
||||
A regression here would not throw and would not look broken in review — the field simply renders the token — so it is pinned by a test rather than left to a reviewer noticing a missing prop.
|
||||
*/
|
||||
it("defaults to a text input", () => {
|
||||
render(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={() => {}} />);
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("type", "text");
|
||||
});
|
||||
|
||||
it("masks a password row and suppresses autofill by default", () => {
|
||||
render(
|
||||
<SettingsTextRow
|
||||
descriptor={{ key: "apiToken", label: "API token", type: "password" }}
|
||||
value="tk_secret"
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
// A password input is deliberately not exposed with the textbox role.
|
||||
const input = document.querySelector("#apiToken") as HTMLInputElement;
|
||||
expect(input).toHaveAttribute("type", "password");
|
||||
expect(input).toHaveValue("tk_secret");
|
||||
// Without this a browser offers to save the operator's API token.
|
||||
expect(input).toHaveAttribute("autocomplete", "off");
|
||||
});
|
||||
|
||||
it("lets a descriptor override autocomplete on a password row", () => {
|
||||
render(
|
||||
<SettingsTextRow
|
||||
descriptor={{ key: "apiToken", label: "API token", type: "password", autoComplete: "new-password" }}
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector("#apiToken")).toHaveAttribute("autocomplete", "new-password");
|
||||
});
|
||||
|
||||
it("renders a url row without forcing autocomplete off", () => {
|
||||
render(
|
||||
<SettingsTextRow
|
||||
descriptor={{ key: "baseUrl", label: "Base URL", type: "url" }}
|
||||
value="https://ntfy.sh"
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveAttribute("type", "url");
|
||||
// autocomplete suppression is a secret-bearing concern, not a URL one.
|
||||
expect(input).not.toHaveAttribute("autocomplete");
|
||||
});
|
||||
|
||||
it("emits null when cleared", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SettingsTextRow descriptor={descriptor} value="Ada" onChange={onChange} clearable />);
|
||||
|
||||
@@ -268,14 +268,14 @@ describe("splitSettingsSave", () => {
|
||||
payload,
|
||||
initialValues: null,
|
||||
initialScopedValues,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
|
||||
expect(globalPatch).toEqual({ gitlabEnabled: false, gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" });
|
||||
expect(projectPatch).toEqual({});
|
||||
});
|
||||
|
||||
it("routes GitLab enable and token settings to project settings outside global general", () => {
|
||||
it("routes GitLab enable and token settings to project settings outside the global source-control section", () => {
|
||||
const initialScopedValues = {
|
||||
global: { gitlabEnabled: false, gitlabAuthToken: "global-token", gitlabAuthTokenType: "group" },
|
||||
project: { gitlabEnabled: true },
|
||||
@@ -291,7 +291,7 @@ describe("splitSettingsSave", () => {
|
||||
payload,
|
||||
initialValues: null,
|
||||
initialScopedValues,
|
||||
activeSection: "merge",
|
||||
activeSection: "source-control",
|
||||
});
|
||||
|
||||
expect(globalPatch).toEqual({});
|
||||
@@ -319,7 +319,7 @@ describe("splitSettingsSave", () => {
|
||||
// genuine global edit.
|
||||
initialValues: { gitlabEnabled: true } as never,
|
||||
initialScopedValues,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
|
||||
expect(globalPatch).toEqual({ gitlabEnabled: true });
|
||||
@@ -336,7 +336,7 @@ describe("splitSettingsSave", () => {
|
||||
payload: { gitlabEnabled: true },
|
||||
initialValues: { gitlabEnabled: true } as never,
|
||||
initialScopedValues,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
|
||||
expect(globalPatch).toEqual({});
|
||||
@@ -354,7 +354,7 @@ describe("splitSettingsSave", () => {
|
||||
// is explicitly present-but-undefined (unset) — the edit must still land.
|
||||
initialValues: { gitlabEnabled: true } as never,
|
||||
initialScopedValues,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
|
||||
expect(globalPatch).toEqual({ gitlabEnabled: true });
|
||||
@@ -375,7 +375,7 @@ describe("splitSettingsSave", () => {
|
||||
payload,
|
||||
initialValues: null,
|
||||
initialScopedValues,
|
||||
activeSection: "merge",
|
||||
activeSection: "source-control",
|
||||
});
|
||||
|
||||
expect(projectPatch).toEqual({ gitlabAuthToken: null, gitlabAuthTokenType: "personal" });
|
||||
@@ -673,7 +673,7 @@ describe("splitSettingsSave", () => {
|
||||
payload,
|
||||
initialValues: {} as never,
|
||||
initialScopedValues: { global: {}, project: {} } as never,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
expect(onGlobal.globalPatch).toMatchObject(payload);
|
||||
expect("gitlabInstanceUrl" in onGlobal.projectPatch).toBe(false);
|
||||
@@ -683,7 +683,7 @@ describe("splitSettingsSave", () => {
|
||||
payload,
|
||||
initialValues: {} as never,
|
||||
initialScopedValues: { global: {}, project: {} } as never,
|
||||
activeSection: "general",
|
||||
activeSection: "source-control",
|
||||
});
|
||||
expect("gitlabInstanceUrl" in onProject.globalPatch).toBe(false);
|
||||
expect("gitlabApiBaseUrl" in onProject.globalPatch).toBe(false);
|
||||
@@ -700,7 +700,7 @@ describe("splitSettingsSave", () => {
|
||||
global: { gitlabInstanceUrl: "https://global.example", gitlabApiBaseUrl: "https://global.example/api/v4" },
|
||||
project: {},
|
||||
} as never,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
expect(onGlobal.globalPatch).toEqual({ gitlabInstanceUrl: null, gitlabApiBaseUrl: null });
|
||||
|
||||
@@ -711,18 +711,18 @@ describe("splitSettingsSave", () => {
|
||||
global: {},
|
||||
project: { gitlabInstanceUrl: "https://project.example", gitlabApiBaseUrl: "https://project.example/api/v4" },
|
||||
} as never,
|
||||
activeSection: "general",
|
||||
activeSection: "source-control",
|
||||
});
|
||||
expect(onProject.projectPatch).toEqual({ gitlabInstanceUrl: null, gitlabApiBaseUrl: null });
|
||||
});
|
||||
|
||||
it("routes githubTrackingDefaultRepo to global only on the global-general section", () => {
|
||||
it("routes githubTrackingDefaultRepo to global only on the source-control-global section", () => {
|
||||
const payloadGlobal: Record<string, unknown> = { githubTrackingDefaultRepo: "org/repo" };
|
||||
const onGlobal = splitSettingsSave({
|
||||
payload: payloadGlobal,
|
||||
initialValues: {} as never,
|
||||
initialScopedValues: { global: {}, project: {} } as never,
|
||||
activeSection: "global-general",
|
||||
activeSection: "source-control-global",
|
||||
});
|
||||
expect(onGlobal.globalPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" });
|
||||
expect("githubTrackingDefaultRepo" in onGlobal.projectPatch).toBe(false);
|
||||
@@ -731,11 +731,11 @@ describe("splitSettingsSave", () => {
|
||||
payload: { githubTrackingDefaultRepo: "org/repo" },
|
||||
initialValues: {} as never,
|
||||
initialScopedValues: { global: {}, project: {} } as never,
|
||||
activeSection: "general",
|
||||
activeSection: "source-control",
|
||||
});
|
||||
expect("githubTrackingDefaultRepo" in onProject.globalPatch).toBe(false);
|
||||
// ...and is instead routed to the project patch on the project-scoped
|
||||
// "general" section, rather than being dropped or erroring.
|
||||
// "source-control" section, rather than being dropped or erroring.
|
||||
expect(onProject.projectPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,7 +93,6 @@ describe("AppearanceSection", () => {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
return (
|
||||
<AppearanceSection
|
||||
scopeBanner={null}
|
||||
form={emptyForm}
|
||||
setForm={vi.fn()}
|
||||
themeMode="dark"
|
||||
@@ -107,9 +106,12 @@ describe("AppearanceSection", () => {
|
||||
|
||||
it("round-trips the session-banner toggle through its setter", () => {
|
||||
render(<AppearanceHost />);
|
||||
const toggle = screen.getByText("Hide AI session notification banners")
|
||||
.closest("label")!
|
||||
.querySelector("input[type=checkbox]") as HTMLInputElement;
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Resolved through the label→control association rather than by walking the DOM. The old `getByText(...).closest("label").querySelector("input")` assumed the label ELEMENT wrapped the checkbox, which was the `checkbox-label` markup's shape; the shared row primitive binds `htmlFor`/`id` instead, so the label is now a sibling of the control and the walk returned null.
|
||||
`getByLabelText` asserts the binding an assistive technology actually uses, so it survives markup changes and additionally fails if that binding is ever broken — which the DOM walk could not detect.
|
||||
*/
|
||||
const toggle = screen.getByLabelText("Hide AI session notification banners") as HTMLInputElement;
|
||||
expect(toggle.checked).toBe(false);
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.checked).toBe(true);
|
||||
@@ -128,7 +130,6 @@ describe("GeneralSection", () => {
|
||||
|
||||
render(
|
||||
<GeneralSection
|
||||
scopeBanner={null}
|
||||
form={emptyForm}
|
||||
setForm={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
@@ -150,7 +151,6 @@ describe("GeneralSection", () => {
|
||||
const [form, setForm] = useState({ allowAbsoluteFileBrowserPaths: false } as SettingsFormState);
|
||||
return (
|
||||
<GeneralSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
addToast={vi.fn()}
|
||||
@@ -178,7 +178,6 @@ describe("NotificationsSection", () => {
|
||||
const setForm = vi.fn();
|
||||
render(
|
||||
<NotificationsSection
|
||||
scopeBanner={null}
|
||||
form={emptyForm}
|
||||
setForm={setForm}
|
||||
testNotificationLoading={{}}
|
||||
@@ -196,7 +195,6 @@ describe("NotificationsSection", () => {
|
||||
it("nests the failure-notification mode field inside the padded provider body", () => {
|
||||
render(
|
||||
<NotificationsSection
|
||||
scopeBanner={null}
|
||||
form={emptyForm}
|
||||
setForm={vi.fn()}
|
||||
testNotificationLoading={{}}
|
||||
@@ -214,7 +212,6 @@ describe("NotificationsSection", () => {
|
||||
it("shows the ntfy topic field only when ntfy is enabled", () => {
|
||||
const { rerender } = render(
|
||||
<NotificationsSection
|
||||
scopeBanner={null}
|
||||
form={{ ntfyEnabled: false } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
testNotificationLoading={{}}
|
||||
@@ -225,7 +222,6 @@ describe("NotificationsSection", () => {
|
||||
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<NotificationsSection
|
||||
scopeBanner={null}
|
||||
form={{ ntfyEnabled: true } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
testNotificationLoading={{}}
|
||||
@@ -238,11 +234,15 @@ describe("NotificationsSection", () => {
|
||||
});
|
||||
|
||||
describe("SecretsSection", () => {
|
||||
it("renders the scope banner, title, and the SecretsView card", () => {
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
The scope-banner assertion went with the banner itself: sections no longer take a `scopeBanner` slot, because one section-level scope claim was false wherever a section mixed scopes. Scope now rides on each row's badge.
|
||||
The rest of the contract — title plus the SecretsView card — is unchanged and still asserted.
|
||||
*/
|
||||
it("renders the title and the SecretsView card", () => {
|
||||
render(
|
||||
<SecretsSection scopeBanner={<div data-testid="scope-banner" />} addToast={vi.fn()} />,
|
||||
<SecretsSection addToast={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByTestId("scope-banner")).toBeInTheDocument();
|
||||
expect(screen.getByText("Secrets")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secrets-view")).toBeInTheDocument();
|
||||
});
|
||||
@@ -264,7 +264,6 @@ describe("WorktreesSection", () => {
|
||||
const onAdd = vi.fn();
|
||||
render(
|
||||
<WorktreesSection
|
||||
scopeBanner={null}
|
||||
form={{ recycleWorktrees: false, worktreeCopyFiles: [".env"] } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
gitRemotes={[]}
|
||||
@@ -301,7 +300,6 @@ describe("WorktreesSection", () => {
|
||||
|
||||
render(
|
||||
<WorktreesSection
|
||||
scopeBanner={null}
|
||||
form={{ recycleWorktrees: false, showWorktreeGrouping: false, worktreeCopyFiles: [] } as SettingsFormState}
|
||||
setForm={setForm}
|
||||
gitRemotes={[]}
|
||||
@@ -326,7 +324,6 @@ describe("WorktreesSection", () => {
|
||||
it("keeps an empty copy-file row reachable when the setting is undefined", () => {
|
||||
render(
|
||||
<WorktreesSection
|
||||
scopeBanner={null}
|
||||
form={{ recycleWorktrees: false, worktreeCopyFiles: undefined } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
gitRemotes={[]}
|
||||
@@ -350,7 +347,6 @@ describe("GlobalModelsSection", () => {
|
||||
const updateLaneThinkingValue = vi.fn();
|
||||
render(
|
||||
<GlobalModelsSection
|
||||
scopeBanner={null}
|
||||
form={{ defaultThinkingLevel: "low" } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
availableModels={[{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }]}
|
||||
@@ -384,7 +380,6 @@ describe("GlobalModelsSection", () => {
|
||||
} as SettingsFormState);
|
||||
return (
|
||||
<GlobalModelsSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
availableModels={[{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true }]}
|
||||
@@ -447,7 +442,6 @@ describe("ProjectModelsSection", () => {
|
||||
it("opts Project Models lane and preset dropdowns into readable menu width", () => {
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{} as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -472,7 +466,6 @@ describe("ProjectModelsSection", () => {
|
||||
it("colocates summarization model controls with AI summarization settings", () => {
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{} as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -509,7 +502,6 @@ describe("ProjectModelsSection", () => {
|
||||
it("keeps summarization controls behind the available-models guard", () => {
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{} as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -535,7 +527,6 @@ describe("ProjectModelsSection", () => {
|
||||
const resetLaneValue = vi.fn();
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{ defaultThinkingLevel: "medium" } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -575,7 +566,6 @@ describe("ProjectModelsSection", () => {
|
||||
} as SettingsFormState);
|
||||
return (
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
models={{
|
||||
@@ -635,7 +625,6 @@ describe("ProjectModelsSection", () => {
|
||||
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{ defaultWorkflowId: "builtin:coding", defaultThinkingLevel: "medium" } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -688,7 +677,6 @@ describe("ProjectModelsSection", () => {
|
||||
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -735,7 +723,6 @@ describe("ProjectModelsSection", () => {
|
||||
|
||||
render(
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
|
||||
setForm={vi.fn()}
|
||||
models={{
|
||||
@@ -796,7 +783,6 @@ describe("ProjectModelsSection", () => {
|
||||
} as SettingsFormState);
|
||||
return (
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setFormState as never}
|
||||
models={models}
|
||||
@@ -823,7 +809,7 @@ describe("ProjectModelsSection", () => {
|
||||
describe("PromptsSection", () => {
|
||||
it("renders the title and mounts AgentPromptsManager", () => {
|
||||
render(
|
||||
<PromptsSection scopeBanner={null} form={emptyForm} setForm={vi.fn()} />,
|
||||
<PromptsSection form={emptyForm} setForm={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText("Prompts")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-prompts-manager")).toBeInTheDocument();
|
||||
@@ -859,7 +845,6 @@ describe("ExperimentalSection", () => {
|
||||
);
|
||||
return (
|
||||
<ExperimentalSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setFormState as never}
|
||||
knownFeatures={knownFeatures}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: Settings mixed px, rem, em, and calc()-over-spacing font sizes across ~10 distinct values with no scale. Declarations now name scale rungs by role — 2xs for badges/pills, xs for help and caption copy, sm for control labels and body, base/md for headings — so type is retuned from the token definitions rather than by sweeping this file. The mobile `font-size: 16px` overrides stay raw on purpose: they are iOS's focus-zoom threshold, not a type rung, and must hold at 16px even if the root font size shrinks. */
|
||||
/* === SettingsModal: all settings-related layout, panel, and component styles ===
|
||||
Extracted from styles.css as part of the Sweep-3 CSS extraction effort.
|
||||
Imported by SettingsModal.tsx (and MemoryView.tsx for shared classes). */
|
||||
@@ -71,7 +72,7 @@
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
@@ -178,7 +179,7 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 1.125rem;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: normal;
|
||||
@@ -304,7 +305,7 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
|
||||
|
||||
.settings-modal-version {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -450,7 +451,7 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -538,14 +539,14 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
||||
|
||||
.backup-stat-value {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.backup-stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
@@ -595,7 +596,7 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -603,7 +604,7 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
||||
.backup-size {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -711,7 +712,7 @@ The Advanced settings preference is a navigation-level disclosure, so keep it vi
|
||||
padding: var(--space-sm);
|
||||
border-bottom: var(--btn-border-width) solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -735,7 +736,7 @@ The Advanced settings preference is a navigation-level disclosure, so keep it vi
|
||||
|
||||
.settings-search-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -760,10 +761,64 @@ The Advanced settings preference is a navigation-level disclosure, so keep it vi
|
||||
.settings-search-results,
|
||||
.settings-search-empty-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Per-setting search results. Sits between the search input and the section nav because it answers the question the operator actually asked — which setting, not which section — and the nav below stays filtered as the coarse view.
|
||||
Scrolls at a fixed max-height rather than growing: the list is capped at 8 entries, but long labels still wrap, and an unbounded block would push the section nav out of view on a short viewport.
|
||||
Sizes name the shared scale (label = xs, section = 2xs) so results read as the same UI as the rows they navigate to.
|
||||
*/
|
||||
.settings-search-hits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: calc(var(--space-2xl) * 6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-search-hit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-search-hit:hover,
|
||||
.settings-search-hit:focus-visible {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.settings-search-hit-label {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
/* The owning section, so two similarly-named settings are distinguishable before navigating. */
|
||||
.settings-search-hit-section {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-search-hits-more {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-search-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -867,7 +922,7 @@ The Advanced settings preference is a navigation-level disclosure, so keep it vi
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
@@ -905,7 +960,7 @@ The simplified Settings surface needs one consistent reading rhythm across legac
|
||||
padding: var(--space-lg) calc(var(--space-xl) + var(--space-sm)) calc(var(--space-xl) + var(--space-md));
|
||||
}
|
||||
|
||||
.settings-content > :is(.settings-scope-banner, .settings-gitlab-disclosure) {
|
||||
.settings-content > .settings-gitlab-disclosure {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
@@ -917,22 +972,74 @@ The simplified Settings surface needs one consistent reading rhythm across legac
|
||||
}
|
||||
|
||||
.settings-content h4.settings-section-heading {
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.settings-content h5.settings-section-heading {
|
||||
font-size: 0.82rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-20:30:
|
||||
Bespoke rows keep the same vertical rhythm as migrated ones.
|
||||
`.settings-field-row` spaces itself with `padding-block: var(--space-sm)`, while a `.form-group` spaced itself with `margin-top: var(--space-md)`. Mixed in one section — which is now the normal case, since some rows deliberately stay bespoke — that produced three different gaps depending on which idioms happened to be adjacent: 16px between two migrated rows, 12px between two bespoke ones, and 20px where they met.
|
||||
Matching the primitive's `padding-block` (rather than giving the primitive a margin) keeps one idiom: every settings row owns its own space and margins never collapse or compound between them.
|
||||
`padding-inline: 0` stays — `.settings-content` already owns the horizontal inset, and `.form-group`'s global `0 var(--space-xl)` would double-indent every bespoke row.
|
||||
*/
|
||||
.settings-content .form-group {
|
||||
padding-inline: 0;
|
||||
margin-top: var(--space-md);
|
||||
margin-top: 0;
|
||||
padding-block: var(--space-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-20:30:
|
||||
Every label in Settings reads the same way, whether its row is migrated or still bespoke.
|
||||
Some rows deliberately stay hand-rolled — a `data-testid` the primitives have no slot for, help copy that interleaves `<code>` or a link, a repeating-row editor — and those inherit the GLOBAL `.form-group label` treatment: 12px, weight 600, uppercase, letter-spaced, muted. So a section rendered "PLAN APPROVAL MODE" in caps directly above "Auto-merge conflict retries" in sentence case. Two idioms on one screen is the exact inconsistency the migration set out to remove, and migrating every last row is not achievable (nor desirable — see the bespoke rows' own FNXC notes).
|
||||
Retuning them here instead of at the global rule is deliberate: `.form-group` is dashboard-wide across 35 non-settings files, and its uppercase treatment is that context's convention, not a bug. This selector is settings-scoped, so it changes Settings only.
|
||||
Declarations mirror `.settings-field-row-label` — one contract, expressed at whatever specificity each markup idiom needs.
|
||||
*/
|
||||
.settings-content .form-group label:not(.checkbox-label) {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-20:30:
|
||||
Bespoke help text (`<small>` inside a form-group) matches `.settings-field-row-help`: same rung, same colour, same measure. Without this a bespoke row's help sat at 12px/1.4 beside a migrated row's at 12.8px/1.5 — close enough to look like a rendering glitch rather than a choice.
|
||||
Still applies to the copy that legitimately stays inline: validation messages (`.field-error`) and the explanatory blurbs that describe a whole block rather than one control.
|
||||
*/
|
||||
.settings-content .form-group small,
|
||||
.settings-content .form-group .form-text {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The label line for a row that is NOT on the shared primitive, so its help can hang off a "?" exactly like a migrated row's.
|
||||
A bespoke row is `<label>` + control + `<small>`; the tip has to sit beside the label, and it cannot go INSIDE the `<label>` — a nested button would both swallow the label's click-to-focus and put an interactive element inside a label, which is invalid. This wrapper puts them on one line as siblings instead.
|
||||
Mirrors `.settings-field-row-head` (same gap, same wrap) so the two idioms produce an identical label line.
|
||||
*/
|
||||
.settings-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* The label inside the wrapper must not also claim the row's full width, or the tip wraps to its own line. */
|
||||
.settings-content .form-group .settings-field-label-row label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-content .checkbox-label {
|
||||
@@ -940,10 +1047,19 @@ The simplified Settings surface needs one consistent reading rhythm across legac
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-18:52:
|
||||
Every text-entry control in Settings resolves to ONE appearance, whatever markup it sits in.
|
||||
The dashboard ships two competing input treatments: `.input`/`.select` (6px 10px at 13px) and the global `.form-group input` rule (`var(--space-sm) var(--space-md)` = 8px 12px at 14px). A control's padding therefore 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), adding the standard class to a control inside one did nothing. Migrated rows sit outside `.form-group` and got 6px 10px; the bespoke rows still inside one got 8px 12px, side by side in the same section.
|
||||
Normalized here rather than by editing the global `.form-group` rule, which 35 non-settings files depend on: this selector is already settings-scoped and already targets exactly the right set (form-group children AND `.input`/`.select`/`.form-input`), so it is the one place that can reconcile them without reaching outside Settings.
|
||||
Values intentionally mirror `.input` in styles.css — it is the dashboard-wide control appearance across ~300 call sites, so Settings matches the rest of the app rather than inventing a third treatment. If `.input` is ever tokenized, this pairs with it.
|
||||
*/
|
||||
.settings-content .form-group :is(input:not([type="checkbox"]), select, textarea),
|
||||
.settings-content :is(.input, .select, .form-input) {
|
||||
min-height: 38px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-content :is(.form-group small, .settings-description, .settings-section-description) {
|
||||
@@ -969,7 +1085,7 @@ The simplified Settings surface needs one consistent reading rhythm across legac
|
||||
|
||||
/* Settings group headers - visual separators between global and project sections */
|
||||
.settings-group-header {
|
||||
font-size: 10px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1106,7 +1222,7 @@ FNXC:SettingsMobile 2026-06-23-09:02:
|
||||
Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract.
|
||||
*/
|
||||
.settings-section-heading {
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
@@ -1129,7 +1245,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
margin: 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1147,7 +1263,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
cursor: pointer;
|
||||
@@ -1187,35 +1303,13 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
}
|
||||
|
||||
/* Scope banner above section content */
|
||||
.settings-scope-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
margin: 0 var(--space-xl) var(--space-xs);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.settings-scope-global {
|
||||
background: color-mix(in srgb, var(--color-info) 8%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-info) 40%, transparent);
|
||||
}
|
||||
.settings-scope-project {
|
||||
background: color-mix(in srgb, var(--color-success) 8%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-success) 40%, transparent);
|
||||
}
|
||||
.settings-scope-mixed {
|
||||
background: color-mix(in srgb, var(--triage) 8%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--triage) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Helper note styling for Settings sections — aligns with form-group horizontal gutters */
|
||||
.settings-note {
|
||||
display: block;
|
||||
padding: 0 var(--space-xl);
|
||||
margin-top: var(--space-xs);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1234,7 +1328,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--accent, #4a90e2);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
@@ -1246,7 +1340,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
|
||||
.settings-overlap-ignore-group code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-button-row {
|
||||
@@ -1266,7 +1360,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
max-height: calc(var(--space-2xl) * 7);
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.settings-url-output {
|
||||
@@ -1278,7 +1372,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-size: var(--font-size-xs);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
@@ -1294,7 +1388,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
|
||||
.settings-qr-preview-label {
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1527,7 +1621,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
.remote-advanced-details > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-size: var(--font-size-xs);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -1596,7 +1690,7 @@ Settings section headings should preserve hierarchy through spacing and type onl
|
||||
.settings-option-details > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-size: var(--font-size-xs);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -1621,7 +1715,7 @@ FN-7453 keeps GitLab's enable switch visible while hiding noisy URL/token fields
|
||||
FNXC:SettingsLayout 2026-07-10-12:20:
|
||||
First-run review video showed the GitLab Configuration box (and its right-flushed "Enable GitLab integration" checkbox) leaking past the alignment line of every other General control, reading as overflow at the panel/viewport edge.
|
||||
Root cause: settings controls get their horizontal gutter from the base `.form-group { padding: 0 var(--space-xl) }`, but this disclosure is a direct `.settings-content` child that carried no gutter of its own, so its bordered box spanned the full content width.
|
||||
Fix the invariant: bordered boxes that are direct settings-content children align their OUTER edge to the form-group gutter via horizontal margins (same convention as `.settings-scope-banner`), desktop `var(--space-xl)` and mobile `var(--space-sm)` to mirror the mobile `.form-group` gutter below.
|
||||
Fix the invariant: bordered boxes that are direct settings-content children align their OUTER edge to the form-group gutter via horizontal margins, desktop `var(--space-xl)` and mobile `var(--space-sm)` to mirror the mobile `.form-group` gutter below.
|
||||
*/
|
||||
.settings-gitlab-disclosure {
|
||||
display: flex;
|
||||
@@ -1705,7 +1799,7 @@ Fix the invariant: bordered boxes that are direct settings-content children alig
|
||||
.remote-cf-advanced-details > summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-size: var(--font-size-xs);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -1771,7 +1865,7 @@ Fix the invariant: bordered boxes that are direct settings-content children alig
|
||||
|
||||
.settings-overlap-path-picker-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1850,7 +1944,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
.remote-status-url {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
font-size: 0.8em;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.remote-provider-option {
|
||||
@@ -1882,7 +1976,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -2014,14 +2108,14 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
.memory-test-result strong,
|
||||
.memory-test-result span {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.memory-test-result small {
|
||||
display: block;
|
||||
margin-top: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.memory-test-result ul {
|
||||
@@ -2042,7 +2136,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
.memory-test-result p {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -2082,7 +2176,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--color-info);
|
||||
background: color-mix(in srgb, var(--color-info) 12%, transparent);
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -2091,13 +2185,13 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.memory-file-summary small {
|
||||
grid-column: 2;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.memory-editor-frame {
|
||||
@@ -2126,7 +2220,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
margin-bottom: var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
border-left: 3px solid var(--text-muted);
|
||||
}
|
||||
@@ -2134,7 +2228,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
margin-bottom: calc(var(--space-sm) + var(--space-xs) / 2);
|
||||
}
|
||||
.auth-group-label {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -2194,7 +2288,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
.auth-hint {
|
||||
display: block;
|
||||
padding: 12px 4px 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 8px;
|
||||
@@ -2217,7 +2311,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
width: 180px;
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -2230,12 +2324,12 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
opacity: 0.6;
|
||||
}
|
||||
.auth-apikey-progress {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
padding-right: 4px;
|
||||
}
|
||||
.auth-apikey-error {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-error);
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -2260,7 +2354,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
|
||||
.auth-device-code-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
|
||||
font-size: var(--font-size-sm);
|
||||
letter-spacing: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-pill);
|
||||
@@ -2274,7 +2368,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
/* === Key Hint === */
|
||||
.auth-key-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
display: inline-block;
|
||||
margin-top: var(--space-xs);
|
||||
@@ -2286,7 +2380,7 @@ Design tokens only (spacing/radius/color vars), no hardcoded px besides 0.
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2327,7 +2421,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2346,12 +2440,12 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
display: block;
|
||||
margin: var(--space-xs) 0 var(--space-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-dim);
|
||||
padding-inline: var(--space-xl);
|
||||
margin-block: 0 var(--space-md);
|
||||
@@ -2435,7 +2529,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
}
|
||||
|
||||
.settings-preset-summary {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -2676,10 +2770,6 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-scope-banner {
|
||||
margin: 0 var(--space-sm) var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.memory-editor-frame {
|
||||
min-height: 65vh;
|
||||
@@ -2837,7 +2927,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
|
||||
.settings-node-routing-note {
|
||||
padding: var(--space-sm);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2849,7 +2939,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
margin-top: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -2858,7 +2948,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -2900,7 +2990,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -2912,7 +3002,7 @@ The header row wraps so the badge drops below the heading on narrow widths inste
|
||||
/* KTD-8: informational note at the bottom of the Node Sync section. */
|
||||
.settings-sync-workflow-note {
|
||||
margin-top: var(--space-md);
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,14 @@ import { PromptsSection } from "./settings/sections/PromptsSection";
|
||||
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 { ScheduledEvalsSection } from "./settings/sections/ScheduledEvalsSection";
|
||||
import { NodeRoutingSection } from "./settings/sections/NodeRoutingSection";
|
||||
import { WorktreesSection } from "./settings/sections/WorktreesSection";
|
||||
import { CommandsSection } from "./settings/sections/CommandsSection";
|
||||
import { MergeSection } from "./settings/sections/MergeSection";
|
||||
import { SourceControlSection } from "./settings/sections/SourceControlSection";
|
||||
import { SourceControlGlobalSection } from "./settings/sections/SourceControlGlobalSection";
|
||||
import { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSection";
|
||||
import { MemorySection } from "./settings/sections/MemorySection";
|
||||
import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection";
|
||||
@@ -79,6 +82,9 @@ import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus";
|
||||
import { type TrackingRepoOption } from "./TrackingRepoSelect";
|
||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||
import { SETTINGS_SEARCH_ENTRIES } from "./settings/search/entries";
|
||||
import { rankSettingsSearchResults, matchedSectionIds } from "./settings/search/match";
|
||||
import { SettingsSearchHighlightProvider } from "./settings/SettingsSearchHighlightContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub star count — fetched once per session, cached in localStorage (1 h).
|
||||
@@ -239,6 +245,25 @@ export type SettingsSection = {
|
||||
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
|
||||
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
|
||||
const ADVANCED_SETTINGS_STORAGE_KEY = "fusion:settings:show-advanced";
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Mirrors `--settings-search-match-duration` in SettingsFieldRow.css: the highlight clears when the row's wash finishes. If this drifts shorter the class is pulled mid-animation and the wash cuts out; longer and the highlight lingers into the operator's next query.
|
||||
*/
|
||||
const SETTINGS_SEARCH_HIGHLIGHT_MS = 1600;
|
||||
|
||||
/** Per-setting results shown before the list is capped; see the hits list. */
|
||||
const SETTINGS_SEARCH_MAX_RESULTS = 8;
|
||||
|
||||
/**
|
||||
* Scroll behavior for landing a search result, honoring reduced-motion.
|
||||
* Follows the inline `matchMedia` idiom used by OAuthManualCodeForm rather than
|
||||
* adding a hook — the dashboard has no shared reduced-motion hook.
|
||||
*/
|
||||
function settingsSearchScrollBehavior(): ScrollBehavior {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "auto";
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
}
|
||||
|
||||
const SETTINGS_NAV_WIDTH_STORAGE_KEY = "fusion:settings-nav-width";
|
||||
const SETTINGS_NAV_DEFAULT_WIDTH = 248;
|
||||
const SETTINGS_NAV_MIN_WIDTH = 200;
|
||||
@@ -312,11 +337,22 @@ export function sectionMatchesSettingsSearch(
|
||||
query: string,
|
||||
label: string,
|
||||
translateSearchKey: (key: string) => string,
|
||||
entryMatchedSectionIds?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (!query || section.isGroupHeader) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
A section also matches when the per-setting index matched any control inside it, which is the path that finds settings the section's own curated keywords never mentioned.
|
||||
Checked before the keyword list because it is the authoritative one: it indexes the label and help text operators actually read, whereas `searchableText` is a hand-written approximation of it.
|
||||
The keyword list still runs as the fallback — sections are migrated to the index incrementally, and an unmigrated section has no entries, so dropping it here would make those sections unsearchable mid-rollout.
|
||||
*/
|
||||
if (entryMatchedSectionIds?.has(section.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
label,
|
||||
...(section.searchableText ?? []),
|
||||
@@ -331,6 +367,7 @@ export function filterSettingsSectionsForSearch(
|
||||
query: string,
|
||||
translateLabel: (section: SettingsSection) => string,
|
||||
translateSearchKey: (key: string) => string,
|
||||
entryMatchedSectionIds?: ReadonlySet<string>,
|
||||
): SettingsSection[] {
|
||||
if (!query) {
|
||||
return sections;
|
||||
@@ -338,7 +375,7 @@ export function filterSettingsSectionsForSearch(
|
||||
|
||||
const matchedIds = new Set(
|
||||
sections
|
||||
.filter((section) => !section.isGroupHeader && sectionMatchesSettingsSearch(section, query, translateLabel(section), translateSearchKey))
|
||||
.filter((section) => !section.isGroupHeader && sectionMatchesSettingsSearch(section, query, translateLabel(section), translateSearchKey, entryMatchedSectionIds))
|
||||
.map((section) => section.id),
|
||||
);
|
||||
|
||||
@@ -363,47 +400,33 @@ function resolveFirstSelectableSettingsSection(sections: SettingsSection[], fall
|
||||
return sections.find((section) => !section.isGroupHeader)?.id ?? fallback;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
FNXC:SettingsNavigation 2026-07-04-00:00:
|
||||
The mobile Settings section picker (`<select>` on narrow viewports) prefixes every
|
||||
section option with its owning group (`Global — `/`Project — `) so entries are
|
||||
unambiguous when labels collide across scopes (e.g. "MCP Servers" exists in both
|
||||
Global and Project). The Authentication section is intentionally `scope: undefined`
|
||||
(it is not backed by settings storage — see SETTINGS_SECTIONS), but it still lives
|
||||
under the Global group header in SETTINGS_SECTIONS, so its mobile option rendered as
|
||||
bare "Authentication" instead of "Global — Authentication", inconsistent with its
|
||||
Global-group siblings (FN-7552). SETTINGS_SECTION_GROUP_LABEL_BY_ID maps every
|
||||
non-header section id to the label of the most recent group-header row preceding it
|
||||
in SETTINGS_SECTIONS, so resolveSettingsSectionOptionLabel can fall back to a
|
||||
group-derived "Global — " prefix for storage-less sections that belong to the Global
|
||||
group — without changing behavior for any section that already declares a scope
|
||||
(Runtimes entries keep their existing scope:"global" path) or for undefined-scope
|
||||
group-header rows themselves (which are never rendered as selectable options).
|
||||
FNXC:SettingsNavigation 2026-07-15-17:35:
|
||||
Scope is written as a ` · Global`/` · Project` SUFFIX, matching the nav labels, rather than the former `Global — `/`Project — ` prefix.
|
||||
The nav is now grouped by topic, so scope is no longer implied by position and the paired sections spell it out in their own label ("MCP Servers · Global"). Keeping the old prefix here would have rendered "Global — MCP Servers · Global" on mobile.
|
||||
The prefix cannot simply be dropped instead: the mobile picker is a bare `<select>` with no room for the scope icon the desktop nav draws, so this suffix is the only scope signal a mobile operator gets — which is why it is still applied to every scoped section, not just the colliding pairs.
|
||||
*/
|
||||
function buildSettingsSectionGroupLabelMap(sections: SettingsSection[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
let currentGroupLabel: string | undefined;
|
||||
for (const section of sections) {
|
||||
if (section.isGroupHeader) {
|
||||
currentGroupLabel = section.label;
|
||||
continue;
|
||||
}
|
||||
if (currentGroupLabel !== undefined) {
|
||||
map.set(section.id, currentGroupLabel);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const SETTINGS_SECTION_SCOPE_SUFFIX = / · (Global|Project)$/;
|
||||
|
||||
/*
|
||||
FNXC:SettingsNavigation 2026-07-15-17:35:
|
||||
Storage-less sections that are nevertheless global in effect (FN-7552: Authentication holds credentials shared across every project, but is not backed by settings storage, hence `scope: undefined`).
|
||||
This is an explicit list because the previous rule — "the most recent group header says Global" — cannot survive topic-first grouping: there is no group named "Global" any more, and Authentication now sits under Integrations. Deriving scope from a group label was always indirect; naming the exception is honest and does not silently lapse when groups are renamed again.
|
||||
*/
|
||||
const STORAGE_LESS_GLOBAL_SECTION_IDS = new Set(["authentication"]);
|
||||
|
||||
function resolveSettingsSectionOptionLabel(section: SettingsSection, label: string): string {
|
||||
if (section.scope === "global") {
|
||||
return `Global — ${label}`;
|
||||
// Paired sections already carry the suffix in their own label; re-appending
|
||||
// would read "MCP Servers · Global · Global".
|
||||
if (SETTINGS_SECTION_SCOPE_SUFFIX.test(label)) {
|
||||
return label;
|
||||
}
|
||||
if (section.scope === "global" || STORAGE_LESS_GLOBAL_SECTION_IDS.has(section.id)) {
|
||||
return `${label} · Global`;
|
||||
}
|
||||
if (section.scope === "project") {
|
||||
return `Project — ${label}`;
|
||||
}
|
||||
if (SETTINGS_SECTION_GROUP_LABEL_BY_ID.get(section.id) === "Global") {
|
||||
return `Global — ${label}`;
|
||||
return `${label} · Project`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
@@ -414,93 +437,25 @@ function resolveMaxAutoMergeRetriesForSettingsForm(settings?: { maxAutoMergeRetr
|
||||
}
|
||||
|
||||
export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
// Global group (shared across all Fusion projects)
|
||||
{ id: "__global_header", label: "Global", labelKey: "settings.nav.globalHeader", scope: undefined, isGroupHeader: true },
|
||||
{ id: "global-general", label: "General", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs", "GitLab instance URL", "global tracking repo"] },
|
||||
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", labelKey: "settings.nav.keyboardShortcuts", scope: "global", searchableText: ["keyboard shortcuts", "hotkeys", "quick chat shortcut", "terminal shortcut", "open files", "open settings", "command center", "new task shortcut", "record shortcut"] },
|
||||
{ id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] },
|
||||
{ id: "__preferences_header", label: "Preferences", labelKey: "settings.nav.preferencesHeader", scope: undefined, isGroupHeader: true },
|
||||
{ id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "task popups", "board list popups", "popup view attachment", "open tasks as popups", "quick chat"] },
|
||||
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", labelKey: "settings.nav.keyboardShortcuts", scope: "global", searchableText: ["keyboard shortcuts", "hotkeys", "quick chat shortcut", "terminal shortcut", "open files", "open settings", "command center", "new task shortcut", "record shortcut"] },
|
||||
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] },
|
||||
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] },
|
||||
{ id: "global-models", label: "Models", 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"] },
|
||||
{ id: "global-mcp", label: "MCP Servers", labelKey: "settings.nav.globalMcp", scope: "global", searchableText: ["global MCP servers", "shared MCP", "user MCP", "tool servers"] },
|
||||
{
|
||||
id: "cli-agents",
|
||||
label: "CLI Agents",
|
||||
labelKey: "settings.nav.cliAgents",
|
||||
scope: "global",
|
||||
searchableText: [
|
||||
"Droid CLI",
|
||||
"Cursor CLI",
|
||||
"agent runtime",
|
||||
"command line agents",
|
||||
"Adapter",
|
||||
"Command override",
|
||||
"Path or name of the binary to launch",
|
||||
"Extra arguments",
|
||||
"Appended after the adapter's computed arguments",
|
||||
"Environment variable additions",
|
||||
"Comma-separated variable names forwarded",
|
||||
"Autonomy mode",
|
||||
"Elevated autonomy requires a per-project approval",
|
||||
],
|
||||
searchableKeys: [
|
||||
"settings.cliAgents.adapterLabel",
|
||||
"settings.cliAgents.commandLabel",
|
||||
"settings.cliAgents.commandHelp",
|
||||
"settings.cliAgents.extraArgsLabel",
|
||||
"settings.cliAgents.extraArgsHelp",
|
||||
"settings.cliAgents.envLabel",
|
||||
"settings.cliAgents.envHelp",
|
||||
"settings.cliAgents.autonomyLabel",
|
||||
"settings.cliAgents.autonomyHelp",
|
||||
"settings.cliAgents.approvedNote",
|
||||
],
|
||||
},
|
||||
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global", searchableText: ["research providers", "external search providers", "fetch limits", "global research defaults", "citations"] },
|
||||
/*
|
||||
FNXC:SettingsNavigation 2026-06-26-09:20:
|
||||
FN-7062 requires the remote settings nav entry to read "Remote Access" only. The stale "& Node Sync" suffix belongs to the separate Node Sync settings section, while this section body already uses the Remote Access heading.
|
||||
*/
|
||||
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global", searchableText: ["cloudflared", "tunnel", "QR", "persistent token", "remote URL"] },
|
||||
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global", searchableText: ["feature flags", "experiments", "research view", "evals view", "sandbox", "subtask breakdown"] },
|
||||
{ id: "global-general", label: "General · Global", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs"] },
|
||||
|
||||
// Runtimes group (plugin runtimes with their own settings)
|
||||
{ id: "__runtimes_header", label: "Runtimes", labelKey: "settings.nav.runtimesHeader", scope: undefined, isGroupHeader: true },
|
||||
{ id: "hermes-runtime", label: "Hermes", labelKey: "settings.nav.hermesRuntime", scope: "global", searchableText: ["Hermes runtime", "plugin runtime", "printer runtime"] },
|
||||
{ id: "openclaw-runtime", label: "OpenClaw", labelKey: "settings.nav.openclawRuntime", scope: "global", searchableText: ["OpenClaw runtime", "plugin runtime", "open claw"] },
|
||||
{ id: "paperclip-runtime", label: "Paperclip", labelKey: "settings.nav.paperclipRuntime", scope: "global", searchableText: ["Paperclip runtime", "plugin runtime"] },
|
||||
|
||||
// Project group (specific to this project)
|
||||
{ id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true },
|
||||
{
|
||||
id: "general",
|
||||
label: "Project General",
|
||||
labelKey: "settings.nav.projectGeneral",
|
||||
scope: "project",
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-16:20:
|
||||
Import auto-translation lives in Project General beside the other import-scoped GitHub settings, but operators look for it by what it DOES ("translate", "language", "auto translate issues"), not by the section it happens to live in. Settings search only matches curated terms plus advertised i18n keys, so without these the controls are effectively unfindable — the section name says nothing about translation.
|
||||
*/
|
||||
searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "GitHub tracking", "GitLab integration", "chat rooms", "auto-cleanup old chats", "translate", "translation", "auto translate", "auto-translate", "autotranslate", "auto translate issues", "translate issues", "translate imported issues", "githubImportAutoTranslate", "importTranslateTargetLocale", "target language", "translation target language", "translation language", "language", "foreign language issues", "import language", "localize", "localization"],
|
||||
searchableKeys: [
|
||||
"settings.general.autoTranslateImportedIssues",
|
||||
"settings.general.autoTranslateImportedIssuesHelp",
|
||||
"settings.general.translationTargetLanguage",
|
||||
"settings.general.translationTargetLanguageHelp",
|
||||
"settings.general.followDashboardLanguage",
|
||||
],
|
||||
},
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-16:20:
|
||||
Import auto-translation lives in Project General beside the other import-scoped GitHub settings, but operators look for it by what it DOES ("translate", "language", "auto translate issues"), not by the section it happens to live in.
|
||||
FNXC:SettingsSearch 2026-07-15-19:10: the per-setting index now matches these controls on their own label and help text, so the terms that merely restate the copy are no longer load-bearing. The list is kept for the genuine vocabulary gaps — "localize", "localization", "foreign language issues" — which appear nowhere in the copy, and because unmigrated siblings in this section still rely on section-level keywords.
|
||||
*/
|
||||
{ id: "general", label: "General · Project", labelKey: "settings.nav.projectGeneral", scope: "project", searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "chat rooms", "auto-cleanup old chats", "translate", "translation", "auto translate", "auto-translate", "autotranslate", "auto translate issues", "translate issues", "translate imported issues", "githubImportAutoTranslate", "importTranslateTargetLocale", "target language", "translation target language", "translation language", "language", "foreign language issues", "import language", "localize", "localization"], searchableKeys: ["settings.general.autoTranslateImportedIssues", "settings.general.autoTranslateImportedIssuesHelp", "settings.general.translationTargetLanguage", "settings.general.translationTargetLanguageHelp", "settings.general.followDashboardLanguage"] },
|
||||
{ id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project", searchableText: ["test command", "build command", "verification command", "workflow scripts", "commands"] },
|
||||
{ id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project", searchableText: ["worktree directory", "copy files", "recycle worktrees", "branch naming", "sibling branch rename"] },
|
||||
{ id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project", searchableText: ["max concurrent", "capacity", "stuck tasks", "poll interval", "parallel steps", "scheduler"] },
|
||||
{ id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project", searchableText: ["scheduled evals", "evaluation schedule", "eval runs", "quality jobs"] },
|
||||
{ id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project", searchableText: ["node routing", "routing rules", "node selection", "execution nodes"] },
|
||||
{ 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: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project", searchableText: ["agent provisioning", "approval", "permissions", "policy", "agent creation"] },
|
||||
{ id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project", searchableText: ["memory backend", "Dreams", "long-term memory", "qmd", "memory file", "retrieval"] },
|
||||
{ id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project", searchableText: ["backup", "restore", "settings export", "settings import"] },
|
||||
{ id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project", searchableText: ["project research", "research runs", "citations", "search limits", "fetch synthesis"] },
|
||||
|
||||
{ id: "__ai_header", label: "AI & Models", labelKey: "settings.nav.aiHeader", scope: undefined, isGroupHeader: true },
|
||||
{ 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:
|
||||
* Project Models owns the FN-7907 Direct-chat default settings. Its shared Settings search index must advertise chat-default terms and i18n labels so desktop nav, the mobile section picker, and filtered search all surface this section when operators search for Chat defaults.
|
||||
@@ -510,7 +465,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
*/
|
||||
{
|
||||
id: "project-models",
|
||||
label: "Project Models",
|
||||
label: "Models · Project",
|
||||
labelKey: "settings.nav.projectModels",
|
||||
scope: "project",
|
||||
searchableText: [
|
||||
@@ -567,16 +522,89 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
"settings.projectModels.whenEnabledMergeCommitMessagesIncludeAnAI",
|
||||
],
|
||||
},
|
||||
{ id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project", searchableText: ["secrets", "secret storage", "environment", "credentials"] },
|
||||
{ id: "mcp", label: "MCP Servers", labelKey: "settings.nav.mcp", scope: "project", searchableText: ["project MCP servers", "workspace MCP", "project tool servers", "mcp config"] },
|
||||
{
|
||||
id: "cli-agents",
|
||||
label: "CLI Agents",
|
||||
labelKey: "settings.nav.cliAgents",
|
||||
scope: "global",
|
||||
searchableText: [
|
||||
"Droid CLI",
|
||||
"Cursor CLI",
|
||||
"agent runtime",
|
||||
"command line agents",
|
||||
"Adapter",
|
||||
"Command override",
|
||||
"Path or name of the binary to launch",
|
||||
"Extra arguments",
|
||||
"Appended after the adapter's computed arguments",
|
||||
"Environment variable additions",
|
||||
"Comma-separated variable names forwarded",
|
||||
"Autonomy mode",
|
||||
"Elevated autonomy requires a per-project approval",
|
||||
],
|
||||
searchableKeys: [
|
||||
"settings.cliAgents.adapterLabel",
|
||||
"settings.cliAgents.commandLabel",
|
||||
"settings.cliAgents.commandHelp",
|
||||
"settings.cliAgents.extraArgsLabel",
|
||||
"settings.cliAgents.extraArgsHelp",
|
||||
"settings.cliAgents.envLabel",
|
||||
"settings.cliAgents.envHelp",
|
||||
"settings.cliAgents.autonomyLabel",
|
||||
"settings.cliAgents.autonomyHelp",
|
||||
"settings.cliAgents.approvedNote",
|
||||
],
|
||||
},
|
||||
{ id: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project", searchableText: ["agent provisioning", "approval", "permissions", "policy", "agent creation"] },
|
||||
{ id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project", searchableText: ["prompt instructions", "PR title prompt", "PR description prompt", "custom prompts"] },
|
||||
{ id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project", searchableText: ["memory backend", "Dreams", "long-term memory", "qmd", "memory file", "retrieval"] },
|
||||
{ id: "research-global", label: "Research · Global", labelKey: "settings.nav.researchGlobal", scope: "global", searchableText: ["research providers", "external search providers", "fetch limits", "global research defaults", "citations"] },
|
||||
{ id: "research-project", label: "Research · Project", labelKey: "settings.nav.researchProject", scope: "project", searchableText: ["project research", "research runs", "citations", "search limits", "fetch synthesis"] },
|
||||
|
||||
{ id: "__automation_header", label: "Automation", labelKey: "settings.nav.automationHeader", scope: undefined, isGroupHeader: true },
|
||||
/*
|
||||
FNXC:SettingsNavigation 2026-07-15-18:52:
|
||||
Scheduling is split into a Global/Project pair rather than one section holding both authority levels behind in-section subheadings. The machine-wide concurrency cap and a project's scheduling posture are different questions, and a search result landing mid-section showed no subheading to disambiguate them.
|
||||
*/
|
||||
{ id: "scheduling-global", label: "Scheduling · Global", labelKey: "settings.nav.schedulingGlobal", scope: "global", searchableText: ["global max concurrent", "concurrency cap", "all projects", "machine wide", "parallel agents", "scheduler"] },
|
||||
{ id: "scheduling", label: "Scheduling · Project", labelKey: "settings.nav.scheduling", scope: "project", searchableText: ["max concurrent", "capacity", "stuck tasks", "poll interval", "parallel steps", "scheduler"] },
|
||||
{ id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project", searchableText: ["scheduled evals", "evaluation schedule", "eval runs", "quality jobs"] },
|
||||
|
||||
{ id: "__integrations_header", label: "Integrations", labelKey: "settings.nav.integrationsHeader", scope: undefined, isGroupHeader: true },
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The Global/Project source-control pair sits under Integrations, not Project: these settings configure how Fusion talks to GitHub/GitLab, which is the same kind of thing as the MCP and provider entries beside them.
|
||||
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: "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"] },
|
||||
{ id: "hermes-runtime", label: "Hermes", labelKey: "settings.nav.hermesRuntime", scope: "global", searchableText: ["Hermes runtime", "plugin runtime", "printer runtime"] },
|
||||
{ id: "openclaw-runtime", label: "OpenClaw", labelKey: "settings.nav.openclawRuntime", scope: "global", searchableText: ["OpenClaw runtime", "plugin runtime", "open claw"] },
|
||||
{ id: "paperclip-runtime", label: "Paperclip", labelKey: "settings.nav.paperclipRuntime", scope: "global", searchableText: ["Paperclip runtime", "plugin runtime"] },
|
||||
{ id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project", searchableText: ["secrets", "secret storage", "environment", "credentials"] },
|
||||
|
||||
{ id: "__infrastructure_header", label: "Infrastructure", labelKey: "settings.nav.infrastructureHeader", scope: undefined, isGroupHeader: true },
|
||||
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] },
|
||||
{ id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project", searchableText: ["node routing", "routing rules", "node selection", "execution nodes"] },
|
||||
/*
|
||||
FNXC:SettingsNavigation 2026-06-26-09:20:
|
||||
FN-7062 requires the remote settings nav entry to read "Remote Access" only. The stale "& Node Sync" suffix belongs to the separate Node Sync settings section, while this section body already uses the Remote Access heading.
|
||||
*/
|
||||
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global", searchableText: ["cloudflared", "tunnel", "QR", "persistent token", "remote URL"] },
|
||||
{ id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project", searchableText: ["backup", "restore", "settings export", "settings import"] },
|
||||
|
||||
{ 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-04-00:00: sectionId -> owning group label ("Global"/"Runtimes"/"Project"),
|
||||
// derived once from SETTINGS_SECTIONS order. Used by resolveSettingsSectionOptionLabel to prefix
|
||||
// storage-less (scope: undefined) sections like "authentication" that belong to the Global group (FN-7552).
|
||||
const SETTINGS_SECTION_GROUP_LABEL_BY_ID = buildSettingsSectionGroupLabelMap(SETTINGS_SECTIONS);
|
||||
|
||||
/** Well-known experimental feature flags with display labels.
|
||||
* These always appear in the Experimental Features settings tab,
|
||||
@@ -1151,6 +1179,11 @@ export function SettingsModal({
|
||||
FNXC:Settings 2026-07-09-00:00:
|
||||
Mobile Settings navigation is controlled by both the viewport hook and the CSS media query because tests and embedded shells can mock one surface independently. Treat either mobile signal as sufficient so the compact picker/search-toggle path stays available whenever Settings is in mobile mode.
|
||||
*/
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
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<string | null>(null);
|
||||
const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() =>
|
||||
viewportMode === "mobile" ||
|
||||
(typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
@@ -1277,12 +1310,34 @@ export function SettingsModal({
|
||||
FNXC:SettingsSearch 2026-07-04-00:00:
|
||||
Operators need Settings search to find the section containing a setting without bypassing feature gates. Search filters only the already-visible section list, matches section labels plus real setting-label/help i18n keys and curated keywords, suppresses empty group headers, and keeps duplicate global/project labels distinguishable in the mobile picker.
|
||||
*/
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Search resolves to individual settings, not just sections: the index carries every control's label and help text, so a query lands on the control instead of on a section the operator must then re-scan by eye.
|
||||
Results are restricted to `visibleSections` so search never surfaces a setting behind a feature gate or hidden by the Advanced switch — the pre-existing contract above, now enforced on the per-setting list as well as the nav.
|
||||
`t` is threaded in as the resolver so results follow the active locale; the index stores i18n keys plus English fallbacks rather than resolved strings.
|
||||
*/
|
||||
const settingsSearchResults = useMemo(() => {
|
||||
if (!normalizedSettingsSearchQuery) return [];
|
||||
const visibleSectionIds = new Set(visibleSections.map((section) => section.id));
|
||||
return rankSettingsSearchResults(
|
||||
SETTINGS_SEARCH_ENTRIES.filter((entry) => visibleSectionIds.has(entry.sectionId)),
|
||||
normalizedSettingsSearchQuery,
|
||||
(key, fallback) => t(key, fallback),
|
||||
);
|
||||
}, [normalizedSettingsSearchQuery, t, visibleSections]);
|
||||
|
||||
const entryMatchedSectionIds = useMemo(
|
||||
() => matchedSectionIds(settingsSearchResults),
|
||||
[settingsSearchResults],
|
||||
);
|
||||
|
||||
const searchMatchedSections = useMemo(() => filterSettingsSectionsForSearch(
|
||||
visibleSections,
|
||||
normalizedSettingsSearchQuery,
|
||||
(section) => t(section.labelKey, section.label),
|
||||
(key) => t(key),
|
||||
), [normalizedSettingsSearchQuery, t, visibleSections]);
|
||||
entryMatchedSectionIds,
|
||||
), [normalizedSettingsSearchQuery, t, visibleSections, entryMatchedSectionIds]);
|
||||
const searchableSectionOptions = searchMatchedSections.filter((section) => !section.isGroupHeader);
|
||||
const hasSettingsSearchQuery = normalizedSettingsSearchQuery.length > 0;
|
||||
const hasSettingsSearchResults = searchableSectionOptions.length > 0;
|
||||
@@ -1292,6 +1347,47 @@ export function SettingsModal({
|
||||
const settingsSearchRowVisible = !isMobileSettingsSearch || mobileSearchRowExpanded;
|
||||
const firstSearchMatchedSectionId = resolveFirstSelectableSettingsSection(searchMatchedSections, firstVisibleSectionId);
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
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) => {
|
||||
setActiveSection(sectionId);
|
||||
setHighlightedSettingKey(key);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedSettingKey) return;
|
||||
const container = settingsContentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const row = container.querySelector<HTMLElement>(`[data-settings-key="${CSS.escape(highlightedSettingKey)}"]`);
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
A result can point at a control the active section does not render at all right now — a conditional field whose dependency is off. Scrolling is skipped rather than guessed at; the section still opens, which is strictly better than the pre-rewrite behavior of only ever opening the section.
|
||||
`CSS.escape` because setting keys reach this selector unsanitized; a key with a dot or colon would otherwise build a selector that throws and take the modal down.
|
||||
*/
|
||||
if (!row) return;
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-18:52:
|
||||
Reveal every `<details>` ancestor before scrolling. Rows inside a collapsed disclosure are in the DOM but not visible, so the jump would scroll to — and highlight — a control the operator cannot see, which reads as search doing nothing.
|
||||
This is not a rare edge: the settings most worth searching for are the ones tucked behind "Advanced" (the ntfy access token, the Cloudflare named-tunnel trio, the Merge option details).
|
||||
Walks ancestors rather than just the nearest one, since disclosures can nest.
|
||||
*/
|
||||
for (let node = row.parentElement; node; node = node.parentElement) {
|
||||
if (node instanceof HTMLDetailsElement) node.open = true;
|
||||
}
|
||||
|
||||
row.scrollIntoView({ block: "center", behavior: settingsSearchScrollBehavior() });
|
||||
|
||||
/*
|
||||
The highlight is a one-shot: it clears itself after the row's wash finishes so it does not persist behind the operator's next query, and so re-selecting the same result re-triggers the animation (an unchanged key would not restart it).
|
||||
*/
|
||||
const timer = window.setTimeout(() => setHighlightedSettingKey(null), SETTINGS_SEARCH_HIGHLIGHT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [highlightedSettingKey, activeSection]);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = visibleSections.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
@@ -1581,8 +1677,13 @@ export function SettingsModal({
|
||||
void refreshSettingsForm(true);
|
||||
}, [addToast, projectId]);
|
||||
|
||||
/*
|
||||
FNXC:SettingsConcurrency 2026-07-15-18:52:
|
||||
Fetches for EITHER scheduling section. `scheduling-global` renders the cap itself, and `scheduling` (project) gates its own concurrency inputs on this load — the FN-era invariant that a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback.
|
||||
Gating on `"scheduling"` alone (the id before the Global/Project split) would leave the global cap's own section waiting on a fetch that never fires, disabling the only control it renders.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (activeSection !== "scheduling" || hasFetchedGlobalConcurrencyRef.current) {
|
||||
if ((activeSection !== "scheduling" && activeSection !== "scheduling-global") || hasFetchedGlobalConcurrencyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1950,7 +2051,8 @@ export function SettingsModal({
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "general") {
|
||||
// FNXC:SourceControl 2026-07-15-20:30: The tracking-repo select moved to the project source-control section; this loader must follow the control it populates or the select renders with no options.
|
||||
if (activeSection !== "source-control") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1984,7 +2086,8 @@ export function SettingsModal({
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "global-general" || globalTrackingRepoLoadedRef.current) {
|
||||
// FNXC:SourceControl 2026-07-15-20:30: Follows the global tracking-repo select into the global source-control section.
|
||||
if (activeSection !== "source-control-global" || globalTrackingRepoLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3131,9 +3234,12 @@ export function SettingsModal({
|
||||
}
|
||||
/*
|
||||
FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
The Global General section must edit raw global GitLab settings, not the merged project-effective form. Otherwise a project override can silently overwrite the global GitLab default on a no-op save.
|
||||
The global source-control section must edit raw global GitLab settings, not the merged project-effective form. Otherwise a project override can silently overwrite the global GitLab default on a no-op save.
|
||||
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
Section id moved with the controls (was "global-general"). This must name whichever section renders the global GitLab rows: a stale id here would send the merged, project-effective values to the global patch — the exact overwrite the scoped-state indirection exists to prevent.
|
||||
*/
|
||||
const gitlabFormForSave = activeSection === "global-general" && globalGitlabSettings ? globalGitlabSettings : form;
|
||||
const gitlabFormForSave = activeSection === "source-control-global" && globalGitlabSettings ? globalGitlabSettings : form;
|
||||
const payload = {
|
||||
...form,
|
||||
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
||||
@@ -3174,7 +3280,8 @@ export function SettingsModal({
|
||||
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
|
||||
};
|
||||
|
||||
if (activeSection === "general") {
|
||||
// FNXC:SourceControl 2026-07-15-20:30: Both GitLab URL-cache refreshes follow their editing sections ("general"/"global-general" before the move).
|
||||
if (activeSection === "source-control") {
|
||||
resolveGitlabConfig({
|
||||
project: {
|
||||
gitlabInstanceUrl: payload.gitlabInstanceUrl,
|
||||
@@ -3182,7 +3289,7 @@ export function SettingsModal({
|
||||
},
|
||||
});
|
||||
}
|
||||
if (activeSection === "global-general") {
|
||||
if (activeSection === "source-control-global") {
|
||||
resolveGitlabConfig({
|
||||
global: {
|
||||
gitlabInstanceUrl: payload.gitlabInstanceUrl,
|
||||
@@ -3500,56 +3607,35 @@ export function SettingsModal({
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
/** Render a scope indicator banner for the current section with theme-aware Lucide icons */
|
||||
const renderScopeBanner = () => {
|
||||
if (activeSectionScope === "global") {
|
||||
return (
|
||||
<div className="settings-scope-banner settings-scope-global">
|
||||
<span className="settings-scope-icon"><Globe size={14} /></span>
|
||||
<span>{t("settings.scope.globalBanner", "These settings are shared across all your Fusion projects.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activeSectionScope === "project") {
|
||||
return (
|
||||
<div className="settings-scope-banner settings-scope-project">
|
||||
<span className="settings-scope-icon"><Folder size={14} /></span>
|
||||
<span>{t("settings.scope.projectBanner", "These settings only affect this project.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderSectionFields = () => {
|
||||
switch (activeSection) {
|
||||
case "cli-agents":
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
|
||||
</>
|
||||
);
|
||||
return <CliAgentsSettingsSection projectId={projectId} addToast={addToast} />;
|
||||
case "general":
|
||||
return (
|
||||
<GeneralSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
prefixError={prefixError}
|
||||
setPrefixError={setPrefixError}
|
||||
projectTrackingRepoOptions={projectTrackingRepoOptions}
|
||||
projectTrackingRepoLoading={projectTrackingRepoLoading}
|
||||
projectTrackingRepoError={projectTrackingRepoError}
|
||||
onQuickChatButtonModeChange={onQuickChatButtonModeChange}
|
||||
/>
|
||||
);
|
||||
case "global-general":
|
||||
case "source-control":
|
||||
return (
|
||||
<GlobalGeneralSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
<SourceControlSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
projectTrackingRepoOptions={projectTrackingRepoOptions}
|
||||
projectTrackingRepoLoading={projectTrackingRepoLoading}
|
||||
projectTrackingRepoError={projectTrackingRepoError}
|
||||
/>
|
||||
);
|
||||
case "source-control-global":
|
||||
return (
|
||||
<SourceControlGlobalSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
globalSettings={globalGitlabSettings}
|
||||
@@ -3566,10 +3652,16 @@ export function SettingsModal({
|
||||
globalTrackingRepoError={globalTrackingRepoError}
|
||||
/>
|
||||
);
|
||||
case "global-general":
|
||||
return (
|
||||
<GlobalGeneralSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
);
|
||||
case "keyboard-shortcuts":
|
||||
return (
|
||||
<KeyboardShortcutsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
@@ -3577,7 +3669,6 @@ export function SettingsModal({
|
||||
case "global-models":
|
||||
return (
|
||||
<GlobalModelsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
availableModels={availableModels}
|
||||
@@ -3596,11 +3687,10 @@ export function SettingsModal({
|
||||
);
|
||||
|
||||
case "secrets":
|
||||
return <SecretsSection scopeBanner={renderScopeBanner()} addToast={addToast} />;
|
||||
return <SecretsSection addToast={addToast} />;
|
||||
case "global-mcp":
|
||||
return (
|
||||
<GlobalMcpSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={mcpFormForScope("global")}
|
||||
setForm={setMcpFormForScope("global")}
|
||||
projectId={projectId}
|
||||
@@ -3610,7 +3700,6 @@ export function SettingsModal({
|
||||
case "mcp":
|
||||
return (
|
||||
<ProjectMcpSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={mcpFormForScope("project")}
|
||||
setForm={setMcpFormForScope("project")}
|
||||
globalSettings={scopedSettings?.global ?? null}
|
||||
@@ -3622,7 +3711,6 @@ export function SettingsModal({
|
||||
case "project-models":
|
||||
return (
|
||||
<ProjectModelsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
projectId={projectId}
|
||||
@@ -3656,7 +3744,6 @@ export function SettingsModal({
|
||||
case "appearance":
|
||||
return (
|
||||
<AppearanceSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
themeMode={themeMode}
|
||||
@@ -3672,18 +3759,23 @@ export function SettingsModal({
|
||||
setSessionBannersHidden={setSessionBannersHidden}
|
||||
/>
|
||||
);
|
||||
case "scheduling":
|
||||
case "scheduling-global":
|
||||
return (
|
||||
<SchedulingSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
<SchedulingGlobalSection
|
||||
globalMaxConcurrent={globalMaxConcurrent}
|
||||
concurrencyLoading={activeSection === "scheduling" && !globalConcurrencyLoaded && !globalConcurrencyDirtyRef.current}
|
||||
concurrencyLoading={activeSection === "scheduling-global" && !globalConcurrencyLoaded && !globalConcurrencyDirtyRef.current}
|
||||
onGlobalMaxConcurrentChange={(value) => {
|
||||
globalConcurrencyDirtyRef.current = true;
|
||||
setGlobalMaxConcurrent(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "scheduling":
|
||||
return (
|
||||
<SchedulingSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
concurrencyLoading={activeSection === "scheduling" && !globalConcurrencyLoaded && !globalConcurrencyDirtyRef.current}
|
||||
onOverlapIgnorePathChange={handleOverlapIgnorePathChange}
|
||||
onOpenOverlapPathPicker={openOverlapPathPicker}
|
||||
onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath}
|
||||
@@ -3694,7 +3786,6 @@ export function SettingsModal({
|
||||
case "scheduled-evals":
|
||||
return (
|
||||
<ScheduledEvalsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
@@ -3702,7 +3793,6 @@ export function SettingsModal({
|
||||
case "node-routing":
|
||||
return (
|
||||
<NodeRoutingSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
nodes={nodes}
|
||||
@@ -3711,7 +3801,6 @@ export function SettingsModal({
|
||||
case "worktrees":
|
||||
return (
|
||||
<WorktreesSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
gitRemotes={gitRemotes}
|
||||
@@ -3728,7 +3817,6 @@ export function SettingsModal({
|
||||
case "commands":
|
||||
return (
|
||||
<CommandsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
@@ -3736,7 +3824,6 @@ export function SettingsModal({
|
||||
case "merge":
|
||||
return (
|
||||
<MergeSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
integrationBranchOptions={integrationBranchOptions}
|
||||
@@ -3750,7 +3837,6 @@ export function SettingsModal({
|
||||
case "agent-permissions":
|
||||
return (
|
||||
<AgentPermissionsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
@@ -3758,7 +3844,6 @@ export function SettingsModal({
|
||||
case "memory":
|
||||
return (
|
||||
<MemorySection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
memory={{
|
||||
@@ -3792,7 +3877,6 @@ export function SettingsModal({
|
||||
case "research-global":
|
||||
return (
|
||||
<ResearchGlobalSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
authProviders={authProviders}
|
||||
@@ -3802,7 +3886,6 @@ export function SettingsModal({
|
||||
case "research-project":
|
||||
return (
|
||||
<ResearchProjectSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
researchLimitError={researchLimitError}
|
||||
@@ -3811,7 +3894,6 @@ export function SettingsModal({
|
||||
case "experimental":
|
||||
return (
|
||||
<ExperimentalSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
knownFeatures={KNOWN_EXPERIMENTAL_FEATURES}
|
||||
@@ -3824,7 +3906,6 @@ export function SettingsModal({
|
||||
case "backups":
|
||||
return (
|
||||
<BackupsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
backupInfo={backupInfo}
|
||||
@@ -3835,7 +3916,6 @@ export function SettingsModal({
|
||||
case "notifications":
|
||||
return (
|
||||
<NotificationsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
testNotificationLoading={testNotificationLoading}
|
||||
@@ -3846,7 +3926,6 @@ export function SettingsModal({
|
||||
case "node-sync":
|
||||
return (
|
||||
<NodeSyncSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
@@ -3854,7 +3933,6 @@ export function SettingsModal({
|
||||
case "remote":
|
||||
return (
|
||||
<RemoteSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
remote={{
|
||||
@@ -3884,7 +3962,6 @@ export function SettingsModal({
|
||||
case "prompts":
|
||||
return (
|
||||
<PromptsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onOpenWorkflowSettings={onOpenWorkflowSettings}
|
||||
@@ -3893,7 +3970,6 @@ export function SettingsModal({
|
||||
case "plugins":
|
||||
return (
|
||||
<PluginsSection
|
||||
scopeBanner={renderScopeBanner()}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
activePluginsSubsection={activePluginsSubsection}
|
||||
@@ -4122,11 +4198,59 @@ export function SettingsModal({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The count reports matching settings, not matching sections: search now resolves to controls, and "3 matching sections" told an operator nothing about whether the setting they wanted was among them.
|
||||
It falls back to the section count while any matched section is still keyword-only (an unmigrated section matches without contributing entries), so the number never under-reports what the nav is showing during the rollout.
|
||||
*/}
|
||||
<div id="settings-search-results" className="settings-search-results" aria-live="polite">
|
||||
{/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Counts read through i18next's plural resolution (`_one`/`_other` in the catalog) rather than a single hardcoded string, which is why no inline English fallback is passed here: a literal defaultValue would win over the catalog's singular form and reinstate "1 matching settings".
|
||||
*/}
|
||||
{hasSettingsSearchQuery
|
||||
? t("settings.search.resultCount", "{{count}} matching sections", { count: searchableSectionOptions.length })
|
||||
? settingsSearchResults.length > 0
|
||||
? t("settings.search.settingResultCount", { count: settingsSearchResults.length })
|
||||
: t("settings.search.resultCount", { count: searchableSectionOptions.length })
|
||||
: t("settings.search.allSections", "Showing all settings sections")}
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Per-setting results: each row names the control and the section holding it, so an operator can tell two similarly-named settings apart before navigating (several sections carry their own "default model").
|
||||
Rendered as a list of buttons rather than a listbox/combobox because selecting one navigates the modal rather than filling the input — the input keeps its own value, and announcing it as a combobox would promise a completion that never happens.
|
||||
Capped for the same reason a nav is: a two-character query matches most of the index, and an unbounded list would bury the search box. The cap is announced below rather than silently truncating.
|
||||
*/}
|
||||
{settingsSearchResults.length > 0 && (
|
||||
<ul className="settings-search-hits" data-testid="settings-search-hits">
|
||||
{settingsSearchResults.slice(0, SETTINGS_SEARCH_MAX_RESULTS).map((result) => {
|
||||
const section = visibleSections.find((s) => s.id === result.sectionId);
|
||||
return (
|
||||
<li key={`${result.sectionId}:${result.key}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-search-hit"
|
||||
data-testid={`settings-search-hit-${result.key}`}
|
||||
onClick={() => handleSettingsSearchResultSelect(result.sectionId, result.key)}
|
||||
>
|
||||
<span className="settings-search-hit-label">{result.label}</span>
|
||||
{section && (
|
||||
<span className="settings-search-hit-section">
|
||||
{t(section.labelKey, section.label)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{settingsSearchResults.length > SETTINGS_SEARCH_MAX_RESULTS && (
|
||||
<li className="settings-search-hits-more">
|
||||
{t("settings.search.moreResults", "{{count}} more — keep typing to narrow", {
|
||||
count: settingsSearchResults.length - SETTINGS_SEARCH_MAX_RESULTS,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -4188,7 +4312,15 @@ export function SettingsModal({
|
||||
ref={settingsContentRef}
|
||||
data-show-advanced={showAdvancedSettings ? "true" : "false"}
|
||||
>
|
||||
{hasSettingsSearchResults ? renderSectionFields() : (
|
||||
{/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Wraps only the section content: the provider's value is the setting a search result asked to highlight, and the rows that consume it all render below here. Scoping it this tightly keeps a highlight change from re-rendering the nav and search box on every jump.
|
||||
*/}
|
||||
{hasSettingsSearchResults ? (
|
||||
<SettingsSearchHighlightProvider highlightedKey={highlightedSettingKey}>
|
||||
{renderSectionFields()}
|
||||
</SettingsSearchHighlightProvider>
|
||||
) : (
|
||||
<div className="settings-empty-state settings-search-content-empty" role="status">
|
||||
<p>{t("settings.search.noResults", "No settings sections match \"{{query}}\".", { query: settingsSearchQuery.trim() })}</p>
|
||||
<button type="button" className="btn" onClick={() => setSettingsSearchQuery("")}>{t("settings.search.clear", "Clear settings search")}</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: Diff labels derived their size from a calc() over spacing tokens, coupling type to layout spacing. The modal now names --font-size-xs directly so the diff and manual-input panes track the settings type scale instead of --space-* changes. */
|
||||
/* === SettingsSyncConflictModal === */
|
||||
.settings-sync-conflict-modal {
|
||||
max-width: 860px;
|
||||
@@ -38,7 +39,7 @@
|
||||
}
|
||||
|
||||
.settings-sync-conflict-modal__diff-label {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -49,7 +50,7 @@
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -74,7 +75,7 @@
|
||||
.settings-sync-conflict-modal__manual-input {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
min-height: 80px;
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: Sync-log rows previously hardcoded 11px/12px/13px, which drifted from the rest of Settings. Rows and filters now name the shared scale rungs (2xs for result/status pills, xs for row copy, sm for the empty state) so one token edit retunes the whole log. */
|
||||
/* === SettingsSyncLog === */
|
||||
.settings-sync-log {
|
||||
display: flex;
|
||||
@@ -50,12 +51,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-sync-log__filters select {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -87,7 +88,7 @@
|
||||
}
|
||||
|
||||
.settings-sync-log__entry-timestamp {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
min-width: 140px;
|
||||
}
|
||||
@@ -102,7 +103,7 @@
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -123,11 +124,11 @@
|
||||
|
||||
.settings-sync-log__entry-node {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.settings-sync-log__entry-details {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -139,7 +140,7 @@
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-md);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
@@ -150,7 +151,7 @@
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
width: fit-content;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: This panel had drifted to six ad-hoc sizes (0.6/0.65/0.7/0.72/0.75rem) with no scale. Dense metadata (ids, sub-labels, customized badges) now names --font-size-2xs and tab/group/empty copy names --font-size-xs, so the panel has two deliberate rungs rather than six accidental ones. */
|
||||
/* WorkflowSettingsPanel (U6 / KTD-1/KTD-2) — sibling of the fields/column panels.
|
||||
* Mirrors .wf-fields-panel layout so the panels read side-by-side; adds an
|
||||
* internal tab pair (Definitions / Values). Design tokens only; animations use
|
||||
@@ -37,7 +38,7 @@
|
||||
.wf-settings-tab {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--font-size-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
@@ -72,7 +73,7 @@
|
||||
}
|
||||
|
||||
.wf-settings-empty {
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -82,7 +83,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.wf-settings-note--info {
|
||||
@@ -137,7 +138,7 @@
|
||||
|
||||
.wf-setting-id-static {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-2, color-mix(in srgb, #ffffff 4%, transparent));
|
||||
padding: 1px 6px;
|
||||
@@ -145,7 +146,7 @@
|
||||
}
|
||||
|
||||
.wf-setting-id-edit {
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent, #4f7cff);
|
||||
@@ -159,7 +160,7 @@
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--ws-warning, #f59e0b);
|
||||
}
|
||||
|
||||
@@ -175,12 +176,12 @@
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-setting-sub > span {
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -189,7 +190,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -210,7 +211,7 @@ Boolean workflow settings live in the editor's left sidebar, so their checkboxes
|
||||
}
|
||||
|
||||
.wf-setting-options-label {
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -268,7 +269,7 @@ Boolean workflow settings live in the editor's left sidebar, so their checkboxes
|
||||
|
||||
.wf-settings-value-group-title {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
@@ -283,7 +284,7 @@ Boolean workflow settings live in the editor's left sidebar, so their checkboxes
|
||||
|
||||
.wf-settings-customized {
|
||||
align-self: flex-start;
|
||||
font-size: 0.6rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent, #4f7cff);
|
||||
@@ -294,7 +295,7 @@ Boolean workflow settings live in the editor's left sidebar, so their checkboxes
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -312,7 +313,7 @@ Boolean workflow settings live in the editor's left sidebar, so their checkboxes
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -341,14 +342,14 @@ Boolean workflow settings live in the editor's left sidebar, so their checkboxes
|
||||
|
||||
.wf-settings-orphan-id {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-settings-orphan-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -253,7 +253,7 @@ describe("SettingsModal", () => {
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("checkbox", { name: /Enable MCP servers for this scope/i }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: /^General$/ }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: /^General · Global$/ }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -292,7 +292,7 @@ describe("SettingsModal", () => {
|
||||
);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Global$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -340,32 +340,32 @@ describe("SettingsModal", () => {
|
||||
// behind the toggle — expand it before interacting with the search input.
|
||||
await settingsModalUser.click(screen.getByLabelText("Show search"));
|
||||
const search = screen.getByTestId("settings-search-input");
|
||||
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Global$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Project$/ })).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.type(search, " ");
|
||||
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Global$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Project$/ })).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.clear(search);
|
||||
await settingsModalUser.type(search, "completion documentation");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /^General$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^General · Global$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Project$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 matching sections")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 matching section")).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.clear(search);
|
||||
await settingsModalUser.type(search, "Autonomy mode");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /^Project General$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^General · Project$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^CLI Agents$/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cli-agents-settings")).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.clear(search);
|
||||
await settingsModalUser.type(search, "research providers");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /^Research Defaults$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Research · Global$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Research$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/No settings sections match/).length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -380,17 +380,24 @@ describe("SettingsModal", () => {
|
||||
const search = screen.getByTestId("settings-search-input");
|
||||
await settingsModalUser.type(search, "mcp");
|
||||
|
||||
const matches = screen.getAllByRole("button", { name: /^MCP Servers$/ });
|
||||
expect(matches).toHaveLength(2);
|
||||
/*
|
||||
FNXC:SettingsNavigation 2026-07-15-17:35:
|
||||
Both MCP sections must be individually identifiable. This previously asserted TWO buttons named exactly "MCP Servers" — it pinned the duplicate-label bug as expected behavior, and the only thing telling the entries apart was the scope icon.
|
||||
The nav is grouped by topic now, so the pair sits adjacent under Integrations and each label states its own scope.
|
||||
*/
|
||||
const mcpMatches = screen.getAllByRole("button", { name: /^MCP Servers · (Global|Project)$/ });
|
||||
expect(mcpMatches).toHaveLength(2);
|
||||
expect(screen.getByRole("button", { name: "MCP Servers · Global" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "MCP Servers · Project" })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 matching sections")).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.clear(search);
|
||||
await settingsModalUser.type(search, "definitely not a setting");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /^MCP Servers$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^MCP Servers · / })).not.toBeInTheDocument();
|
||||
await settingsModalUser.click(screen.getAllByRole("button", { name: "Clear settings search" })[0]);
|
||||
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Global$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Project$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps settings file pickers workspace-confined even when absolute browsing exists", async () => {
|
||||
@@ -451,13 +458,13 @@ describe("SettingsModal", () => {
|
||||
await settingsModalUser.click(screen.getByLabelText("Show search"));
|
||||
const search = screen.getByTestId("settings-search-input");
|
||||
await settingsModalUser.type(search, "model pricing");
|
||||
expect(screen.getByRole("button", { name: /^Models$/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Project General$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Models · Global$/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^General · Project$/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(search, { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(search).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^General · Project$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the overlay and Escape-to-close in modal mode", async () => {
|
||||
@@ -640,27 +647,36 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
describe("deferred settings fetches", () => {
|
||||
it("does not fetch global concurrency until Scheduling is selected", async () => {
|
||||
/*
|
||||
FNXC:SettingsConcurrency 2026-07-15-18:52:
|
||||
`/Scheduling/` now matches two nav buttons — the section split into a Global/Project pair — so the selector names the exact one. The deferral requirement is unchanged: the global-concurrency endpoint is not hit until a scheduling section is opened.
|
||||
*/
|
||||
it("does not fetch global concurrency until a Scheduling section is selected", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(mockFetchGlobalConcurrency).not.toHaveBeenCalled();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: /Scheduling/ }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Global" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchGlobalConcurrency).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SettingsConcurrency 2026-07-15-18:52:
|
||||
The invariant is unchanged — a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. Only its surface moved: the global cap now lives in its own section, so the assertion follows it across both halves of the pair rather than reading them all off one screen.
|
||||
*/
|
||||
it("disables concurrency inputs until their actual values load", async () => {
|
||||
mockFetchGlobalConcurrency.mockReturnValue(new Promise(() => {}));
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: /Scheduling/ }));
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Global" }));
|
||||
expect(screen.getByLabelText("Global Max Concurrent")).toBeDisabled();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Max Triage Concurrent")).toBeDisabled();
|
||||
});
|
||||
@@ -694,13 +710,18 @@ describe("SettingsModal", () => {
|
||||
|
||||
// Read-only default-render assertions are merged into one rendered
|
||||
// instance to avoid re-rendering the full modal per pure-display check.
|
||||
it("renders default global logging fields, helper text, and tracking repo control", async () => {
|
||||
it("renders default global logging fields and helper text", async () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
// Global modal outside-dismiss and persistAgentToolOutput default to unchecked; Star-on-GitHub control absent.
|
||||
expect(screen.getByRole("checkbox", { name: "Dismiss modals by clicking outside" })).not.toBeChecked();
|
||||
expect(screen.getByText(/Default: disabled, to prevent accidental dismissal/i).closest("small")).toBeTruthy();
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-22:10:
|
||||
Migrated rows render help through the shared primitive rather than a bespoke `<small>`. The copy now lives in the help tip's bubble (`.settings-help-bubble`) instead of an inline `.settings-field-row-help` paragraph — deferred visually, but still in the DOM and the accessibility tree, which is why `getByText` still resolves it.
|
||||
The assertion's intent is unchanged: this row's help must come from the primitive, not hand-rolled markup.
|
||||
*/
|
||||
expect(screen.getByText(/Default: disabled, to prevent accidental dismissal/i).closest(".settings-help-bubble")).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked();
|
||||
expect(screen.queryByRole("checkbox", { name: /Show "Star on GitHub" button in Settings header/i })).toBeNull();
|
||||
|
||||
@@ -708,14 +729,34 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" })).not.toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).not.toBeChecked();
|
||||
|
||||
// Helper descriptions render as small text (not .settings-field-help).
|
||||
// Migrated rows source help from the primitive (now its help tip); the still-bespoke
|
||||
// thinking-log group, whose one help string covers two checkboxes, keeps its <small>.
|
||||
expect(document.querySelector(".settings-field-help")).toBeNull();
|
||||
const toolOutputHelper = screen.getByText(/When disabled, tool rows are still logged but detailed tool payloads are omitted/i);
|
||||
expect(toolOutputHelper.closest("small")).toBeTruthy();
|
||||
expect(toolOutputHelper.closest(".settings-help-bubble")).toBeTruthy();
|
||||
const thinkingHelper = screen.getByText(/Leave both thinking toggles off to keep the original default behavior/i);
|
||||
expect(thinkingHelper.closest("small")).toBeTruthy();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The tracking-repo control and the GitLab disclosure moved to "Source Control · Global". Asserting they are GONE from here (not just present there) is the half that catches a partial move: a section left rendering a second copy of a dual-scope control is exactly the duplicate-`gitlabEnabled` bug this split removed, and it would leave every positive assertion green.
|
||||
*/
|
||||
it("no longer renders the moved source-control controls", async () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.queryByRole("combobox", { name: "Global default tracking repo" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("global-gitlab-configuration-disclosure")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Enable GitLab integration")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Global GitLab instance URL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Global GitLab access token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the moved global tracking repo control and its inheritance hint in Source Control · Global", async () => {
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
// Global default tracking repo control + inheritance hint render.
|
||||
expect(screen.getByRole("combobox", { name: "Global default tracking repo" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -835,7 +876,7 @@ describe("SettingsModal", () => {
|
||||
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||
mockFetchGitRemotes.mockResolvedValueOnce([{ name: "origin", owner: "octo", repo: "global-default", url: "https://github.com/octo/global-default.git" }]);
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -859,7 +900,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("saves GitLab URL configuration via global settings payload only", async () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.getByLabelText("Global GitLab instance URL")).toHaveAttribute("placeholder", "https://gitlab.com");
|
||||
@@ -890,7 +931,7 @@ describe("SettingsModal", () => {
|
||||
project: { gitlabEnabled: true },
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const enableToggle = screen.getByLabelText("Enable GitLab integration") as HTMLInputElement;
|
||||
@@ -909,7 +950,7 @@ describe("SettingsModal", () => {
|
||||
project: { gitlabEnabled: true },
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByLabelText("Enable GitLab integration"));
|
||||
@@ -944,7 +985,7 @@ describe("SettingsModal", () => {
|
||||
project: {},
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const enableToggle = screen.getByLabelText("Enable GitLab integration") as HTMLInputElement;
|
||||
@@ -962,7 +1003,7 @@ describe("SettingsModal", () => {
|
||||
it("shows global tracking repo error hint and keeps custom entry when lookups fail", async () => {
|
||||
mockFetchProjects.mockRejectedValueOnce(new Error("no projects"));
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
renderModal({ initialSection: "source-control-global" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(await screen.findByText(/Could not load project list/i)).toBeInTheDocument();
|
||||
@@ -993,7 +1034,7 @@ describe("SettingsModal", () => {
|
||||
expect(payload.agentProvisioning?.approvalMode).toBe("always");
|
||||
});
|
||||
|
||||
describe("Project General", () => {
|
||||
describe("General · Project", () => {
|
||||
it("renders completion documentation automation control", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
@@ -1026,7 +1067,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
it.each<PersistSettingInput>([
|
||||
{
|
||||
section: "Project General",
|
||||
section: "General · Project",
|
||||
label: "Completion Documentation Automation",
|
||||
kind: "select",
|
||||
value: "changeset",
|
||||
@@ -1034,7 +1075,7 @@ describe("SettingsModal", () => {
|
||||
expectedKey: "completionDocumentationMode",
|
||||
},
|
||||
{
|
||||
section: "Project General",
|
||||
section: "General · Project",
|
||||
label: "Auto-cleanup old chats",
|
||||
kind: "select",
|
||||
value: 14,
|
||||
@@ -1042,7 +1083,7 @@ describe("SettingsModal", () => {
|
||||
expectedKey: "chatAutoCleanupDays",
|
||||
},
|
||||
{
|
||||
section: "Project General",
|
||||
section: "General · Project",
|
||||
label: "Close Quick Chat on outside click",
|
||||
kind: "checkbox",
|
||||
value: false,
|
||||
@@ -1050,7 +1091,7 @@ describe("SettingsModal", () => {
|
||||
expectedKey: "quickChatCloseOnOutsideClick",
|
||||
},
|
||||
{
|
||||
section: "Project General",
|
||||
section: "General · Project",
|
||||
label: "Show task chats in common Chat feed",
|
||||
kind: "checkbox",
|
||||
value: true,
|
||||
@@ -1058,7 +1099,7 @@ describe("SettingsModal", () => {
|
||||
expectedKey: "showTaskChatsInCommonFeed",
|
||||
},
|
||||
{
|
||||
section: "Project General",
|
||||
section: "General · Project",
|
||||
label: "Operational log retention",
|
||||
kind: "select",
|
||||
value: 7,
|
||||
@@ -1091,7 +1132,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Project General" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "General · Project" }));
|
||||
|
||||
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
|
||||
expect(ephemeralToggle).toBeInTheDocument();
|
||||
@@ -1120,7 +1161,7 @@ describe("SettingsModal", () => {
|
||||
expect(sectionPicker).toBeInTheDocument();
|
||||
const projectGeneralOption = sectionPicker.querySelector('option[value="general"]');
|
||||
expect(projectGeneralOption).toBeInTheDocument();
|
||||
expect(projectGeneralOption).toHaveTextContent("Project General");
|
||||
expect(projectGeneralOption).toHaveTextContent("General · Project");
|
||||
|
||||
await settingsModalUser.selectOptions(sectionPicker, "general");
|
||||
|
||||
@@ -1223,8 +1264,8 @@ describe("SettingsModal", () => {
|
||||
expect(payload.chatRoomSummaryMaxChars).toBe(900);
|
||||
});
|
||||
|
||||
it("renders and saves GitHub tracking controls in the General section", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
it("renders and saves GitHub tracking controls in the Source Control section", async () => {
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "GitHub Tracking" })).toBeInTheDocument();
|
||||
@@ -1253,7 +1294,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("renders and saves GitLab URL configuration as project settings", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const disclosure = screen.getByTestId("project-gitlab-configuration-disclosure");
|
||||
@@ -1301,7 +1342,7 @@ describe("SettingsModal", () => {
|
||||
},
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByLabelText("Enable GitLab integration"));
|
||||
@@ -1330,7 +1371,7 @@ describe("SettingsModal", () => {
|
||||
},
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.clear(screen.getByLabelText("GitLab instance URL"));
|
||||
@@ -1348,7 +1389,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("renders and saves imported GitHub issue tracking linking as a project setting", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const importLinkToggle = screen.getByLabelText(
|
||||
@@ -1383,7 +1424,7 @@ describe("SettingsModal", () => {
|
||||
project: { githubLinkImportedIssuesToTracking: true },
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const importLinkToggle = screen.getByLabelText(
|
||||
@@ -1413,7 +1454,7 @@ describe("SettingsModal", () => {
|
||||
githubTrackingDefaultRepo: "octo/existing",
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
|
||||
@@ -1436,7 +1477,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("renders github dedup toggle as checked when project value is unset", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const dedupToggle = screen.getByLabelText(
|
||||
@@ -1451,7 +1492,7 @@ describe("SettingsModal", () => {
|
||||
githubTrackingDedupEnabled: false,
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const dedupToggle = screen.getByLabelText(
|
||||
@@ -1461,7 +1502,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("saves github dedup toggle changes", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const dedupToggle = screen.getByLabelText(
|
||||
@@ -1495,7 +1536,7 @@ describe("SettingsModal", () => {
|
||||
renderModal({ initialSection: "models" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Project Models" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Models · Project" }));
|
||||
|
||||
expect(screen.queryByText("Title, commit message, and GitHub tracking issue summarization model")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -1509,7 +1550,7 @@ describe("SettingsModal", () => {
|
||||
renderModal({ initialSection: "models" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Project Models" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Models · Project" }));
|
||||
|
||||
expect(screen.queryByText(/model used for summarization now lives on the workflow/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/per-phase model lanes \(execution, planning, reviewer, and their fallbacks\) now live on the workflow/i)).toBeInTheDocument();
|
||||
@@ -1520,7 +1561,7 @@ describe("SettingsModal", () => {
|
||||
{ name: "origin", owner: "octo", repo: "repo", url: "https://github.com/octo/repo.git" },
|
||||
]);
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
|
||||
@@ -1540,7 +1581,7 @@ describe("SettingsModal", () => {
|
||||
it("shows project tracking repo error hint and keeps custom entry when remotes fail", async () => {
|
||||
mockFetchGitRemotes.mockRejectedValueOnce(new Error("remotes failed"));
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(await screen.findByText(/Could not load detected remotes/i)).toBeInTheDocument();
|
||||
@@ -1550,7 +1591,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("always shows GitHub tracking summarization helper copy", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(
|
||||
@@ -1683,8 +1724,13 @@ describe("SettingsModal", () => {
|
||||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.autoMerge).toBeNull();
|
||||
expect(payload.mergeStrategy).toBeNull();
|
||||
expect(payload.gitlabAuthToken).toBeNull();
|
||||
// Not part of "merge" — owned by "general" instead; must not leak in.
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
Merge's reset no longer touches ANY forge key — they all moved to "source-control". This used to assert only that `gitlabEnabled` stayed out while `gitlabAuthToken` was reset from here, which was the registry arbitrating a key two sections rendered.
|
||||
*/
|
||||
expect(payload).not.toHaveProperty("gitlabAuthToken");
|
||||
expect(payload).not.toHaveProperty("gitlabAuthTokenType");
|
||||
expect(payload).not.toHaveProperty("githubAuthMode");
|
||||
expect(payload).not.toHaveProperty("gitlabEnabled");
|
||||
expect(payload).not.toHaveProperty("taskPrefix");
|
||||
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();
|
||||
|
||||
@@ -141,12 +141,12 @@ afterEach(() => {
|
||||
|
||||
describe("MCP Settings UI", () => {
|
||||
it("renders global and project MCP section affordances without a new lazy view", async () => {
|
||||
render(<GlobalMcpSection scopeBanner={<div>Global scope</div>} form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
||||
render(<GlobalMcpSection form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
||||
expect(await screen.findByTestId("mcp-servers-card-global")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Add server/i })).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
render(<ProjectMcpSection scopeBanner={<div>Project scope</div>} form={{} as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} addToast={vi.fn()} />);
|
||||
render(<ProjectMcpSection form={{} as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} addToast={vi.fn()} />);
|
||||
expect(await screen.findByTestId("mcp-servers-card-project")).toBeInTheDocument();
|
||||
expect(screen.getByText("No MCP servers configured.")).toBeInTheDocument();
|
||||
});
|
||||
@@ -252,13 +252,13 @@ describe("MCP Settings UI", () => {
|
||||
|
||||
it("renders discovered MCP regions in both global and project cards", async () => {
|
||||
mockFetch({}, { global: discoveredResponse("global"), project: discoveredResponse("project") });
|
||||
render(<GlobalMcpSection scopeBanner={<div>Global scope</div>} form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
||||
render(<GlobalMcpSection form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
||||
const globalDiscovery = await screen.findByTestId("mcp-discovery-global");
|
||||
expect(within(globalDiscovery).getByText("Discovered on this machine")).toBeInTheDocument();
|
||||
expect(within(globalDiscovery).getByText("global-plain")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
render(<ProjectMcpSection scopeBanner={<div>Project scope</div>} form={{} as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} addToast={vi.fn()} />);
|
||||
render(<ProjectMcpSection form={{} as Settings} setForm={vi.fn()} globalSettings={{ mcpServers: { enabled: true, servers: [] } }} addToast={vi.fn()} />);
|
||||
const projectDiscovery = await screen.findByTestId("mcp-discovery-project");
|
||||
expect(within(projectDiscovery).getByText("VS Code project")).toBeInTheDocument();
|
||||
expect(within(projectDiscovery).getByText("project source: skipped malformed config")).toBeInTheDocument();
|
||||
|
||||
@@ -216,7 +216,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
await expectSettingPersists({
|
||||
section: "models",
|
||||
section: "models · global",
|
||||
label: "Sync opencode-go model list at startup",
|
||||
kind: "checkbox",
|
||||
value: false,
|
||||
@@ -235,7 +235,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Models" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Models · Global" }));
|
||||
await settingsModalUser.click(screen.getByText("OpenRouter advanced"));
|
||||
|
||||
expect(screen.getByLabelText("OpenRouter HTTP-Referer")).toBeInTheDocument();
|
||||
@@ -302,7 +302,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Project Models" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Models · Project" }));
|
||||
|
||||
expect(screen.getByText(/The Project Default Model is the fallback for this project/i)).toBeInTheDocument();
|
||||
|
||||
@@ -334,7 +334,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Project Models" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Models · Project" }));
|
||||
await settingsModalUser.click(screen.getByLabelText("Project Default Model"));
|
||||
await settingsModalUser.click(screen.getByText("GPT-4o"));
|
||||
await settingsModalUser.click(screen.getByText("Save"));
|
||||
@@ -608,7 +608,7 @@ describe("SettingsModal", () => {
|
||||
await settingsModalUser.click(await screen.findByText("GPT-4o"));
|
||||
expect(within(screen.getByTestId("workflow-model-lane-planning")).getByText("GPT-4o")).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "General" }));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "General · Global" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("workflow-model-lane-planning")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -774,7 +774,7 @@ describe("SettingsModal", () => {
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument();
|
||||
await settingsModalUser.click(screen.getByText("Scheduling & Capacity"));
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1211,7 +1211,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
const openResearchGlobalSection = async () => {
|
||||
await settingsModalUser.click(await screen.findByRole("button", { name: /Research Defaults/i }));
|
||||
await settingsModalUser.click(await screen.findByRole("button", { name: /Research · Global/i }));
|
||||
};
|
||||
|
||||
const openResearchProjectSection = async () => {
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).toBeChecked();
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).not.toBeChecked();
|
||||
});
|
||||
@@ -229,7 +229,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
await settingsModalUser.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i));
|
||||
await settingsModalUser.type(screen.getByPlaceholderText("docs/"), "generated/*");
|
||||
@@ -254,7 +254,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
await settingsModalUser.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i));
|
||||
await settingsModalUser.click(screen.getByText("Save"));
|
||||
@@ -274,7 +274,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
expect(screen.getByDisplayValue("docs/")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
|
||||
@@ -284,7 +284,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
||||
|
||||
@@ -298,7 +298,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
|
||||
await settingsModalUser.click(await screen.findByRole("button", { name: "Select README.md" }));
|
||||
@@ -326,7 +326,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement;
|
||||
expect(select.value).toBe("lite");
|
||||
@@ -355,7 +355,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked);
|
||||
});
|
||||
@@ -369,7 +369,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement;
|
||||
expect(toggle.checked).toBe(false);
|
||||
@@ -393,7 +393,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement;
|
||||
expect(toggle.checked).toBe(true);
|
||||
@@ -415,7 +415,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Scheduling section
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
@@ -426,12 +426,15 @@ describe("SettingsModal", () => {
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
The machine-wide cap moved to its own `Scheduling · Global` section when Scheduling was split by scope, so this navigates there. The requirement is unchanged: clearing the field must leave it empty rather than snapping to a stuck "0".
|
||||
*/
|
||||
it("allows clearing globalMaxConcurrent without leaving a stuck zero", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Scheduling section
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Global" }));
|
||||
|
||||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
@@ -447,7 +450,7 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Open Scheduling section
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
@@ -461,7 +464,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling & Capacity"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
|
||||
const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
@@ -1201,20 +1204,24 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merge option descriptions are hidden behind disclosure by default", () => {
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-23:20:
|
||||
Merge's bespoke "More details" `<details>` disclosures were replaced by the shared help tip, so these two tests are re-pointed at the COPY and the tip's open/closed state rather than at the removed `<details>`/`<summary>` elements. The intent is unchanged: this help is deferred by default and revealed on demand.
|
||||
Visibility is asserted via the trigger's `aria-expanded`, not `toBeVisible()`: the bubble's collapsed state is CSS-driven and the copy stays in the DOM at all times (so find-in-page, assistive tech, and settings search still reach it), which jsdom reports as visible either way.
|
||||
*/
|
||||
it("merge option descriptions are hidden behind the help tip by default", () => {
|
||||
const autoMergeDescription = screen.getByText(/When enabled, tasks that pass review are automatically merged/i);
|
||||
const disclosure = autoMergeDescription.closest("details");
|
||||
|
||||
expect(disclosure).not.toBeNull();
|
||||
expect(disclosure).not.toHaveAttribute("open");
|
||||
expect(autoMergeDescription).not.toBeVisible();
|
||||
expect(autoMergeDescription.closest(".settings-help-bubble")).toBeTruthy();
|
||||
expect(screen.getByTestId("settings-help-autoMerge")).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("merge option descriptions are revealed when clicking More details", async () => {
|
||||
const moreDetailsSummaries = screen.getAllByText("More details");
|
||||
await settingsModalUser.click(moreDetailsSummaries[0]);
|
||||
it("merge option descriptions are revealed when clicking the help tip", async () => {
|
||||
const helpTrigger = screen.getByTestId("settings-help-autoMerge");
|
||||
await settingsModalUser.click(helpTrigger);
|
||||
|
||||
expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeVisible();
|
||||
expect(helpTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("no longer renders the moved workflow revision fork checkbox", () => {
|
||||
@@ -1368,12 +1375,28 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps GitHub tracking controls out of Merge and preserves GitHub authentication controls", async () => {
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
GitHub authentication moved OUT of Merge into "Source Control · Project", joining the tracking controls that were already absent here. Merge must now hold neither: it owns the landing strategy, not the forge credentials it consumes.
|
||||
*/
|
||||
it("keeps GitHub tracking AND GitHub authentication controls out of Merge", async () => {
|
||||
renderModal({ initialSection: "merge" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.queryByLabelText("Default tracking mode for new tasks")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Project default tracking repo")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "GitHub Authentication" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("GitHub auth mode")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "GitLab Authentication" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("GitLab access token")).not.toBeInTheDocument();
|
||||
// The duplicate `gitlabEnabled` toggle this section used to render is gone.
|
||||
expect(screen.queryByLabelText("Enable GitLab integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders and saves GitHub authentication controls in Source Control", async () => {
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "GitHub Authentication" })).toBeInTheDocument();
|
||||
|
||||
const authModeSelect = screen.getByLabelText("GitHub auth mode") as HTMLSelectElement;
|
||||
@@ -1393,15 +1416,22 @@ describe("SettingsModal", () => {
|
||||
expect(payload.githubAuthToken).toBe("ghp_test_token");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The GitLab URL disclosure and the GitLab auth disclosure merged into ONE disclosure with ONE `gitlabEnabled` toggle, so this reads the configuration disclosure (`project-gitlab-configuration-disclosure`) and opens it by its own summary title. The auth block keeps its heading inside that disclosure; only the second enable toggle went away.
|
||||
*/
|
||||
it("renders GitLab authentication controls as secret-safe project settings", async () => {
|
||||
renderModal({ initialSection: "merge" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const disclosure = screen.getByTestId("project-gitlab-authentication-disclosure");
|
||||
const disclosure = screen.getByTestId("project-gitlab-configuration-disclosure");
|
||||
expect(disclosure).not.toHaveAttribute("open");
|
||||
await settingsModalUser.click(within(disclosure).getByText("GitLab Authentication"));
|
||||
await settingsModalUser.click(within(disclosure).getByText("GitLab Configuration"));
|
||||
expect(disclosure).toHaveAttribute("open");
|
||||
|
||||
// Exactly one enable toggle governs the whole GitLab block.
|
||||
expect(screen.getAllByLabelText("Enable GitLab integration")).toHaveLength(1);
|
||||
expect(screen.queryByTestId("project-gitlab-authentication-disclosure")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "GitLab Authentication" })).toBeInTheDocument();
|
||||
const tokenInput = screen.getByLabelText("GitLab access token") as HTMLInputElement;
|
||||
expect(tokenInput.type).toBe("password");
|
||||
@@ -1410,8 +1440,8 @@ describe("SettingsModal", () => {
|
||||
expect((screen.getByLabelText("GitLab token type") as HTMLSelectElement).value).toBe("personal");
|
||||
});
|
||||
|
||||
it.each(["personal", "project", "group"] as const)("saves a %s GitLab access token from Merge", async (tokenType) => {
|
||||
renderModal({ initialSection: "merge" });
|
||||
it.each(["personal", "project", "group"] as const)("saves a %s GitLab access token from Source Control", async (tokenType) => {
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.selectOptions(screen.getByLabelText("GitLab token type"), tokenType);
|
||||
@@ -1439,7 +1469,7 @@ describe("SettingsModal", () => {
|
||||
project: { gitlabAuthToken: "saved-token", gitlabAuthTokenType: "group" },
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "merge" });
|
||||
renderModal({ initialSection: "source-control" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await settingsModalUser.clear(screen.getByLabelText("GitLab access token"));
|
||||
|
||||
@@ -159,10 +159,17 @@ describe("SettingsModal Node Routing section", () => {
|
||||
expect(screen.getByText(/Configure how tasks are routed to execution nodes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows project scope banner", async () => {
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
Was "shows project scope banner". The section-level banner is removed — it asserted one scope for a whole section, which was false wherever a section mixed them — so scope now rides on each row's badge.
|
||||
The requirement is unchanged (an operator can tell these settings are project-scoped); only the element carrying it moved.
|
||||
*/
|
||||
it("marks its settings as project-scoped", async () => {
|
||||
renderModal();
|
||||
await openNodeRouting();
|
||||
expect(screen.getByText(/These settings only affect this project\./i)).toBeInTheDocument();
|
||||
const badges = screen.getAllByTestId("settings-field-row-scope");
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
expect(badges.map((b) => b.textContent)).toContain("project");
|
||||
});
|
||||
|
||||
it("shows local execution selected when no default node is set", async () => {
|
||||
@@ -245,7 +252,7 @@ describe("SettingsModal Node Routing section", () => {
|
||||
it("removes routing controls from scheduling section", async () => {
|
||||
renderModal();
|
||||
await ready();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling & Capacity" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scheduling · Project" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Scheduling" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -435,7 +435,7 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
|
||||
const picker = getByLabelText("Settings Section") as HTMLSelectElement;
|
||||
const labels = Array.from(picker.options).map((opt) => opt.textContent);
|
||||
expect(labels).toEqual(["Global — MCP Servers", "Project — MCP Servers"]);
|
||||
expect(labels).toEqual(["MCP Servers · Global", "MCP Servers · Project"]);
|
||||
expect(Array.from(picker.options).map((opt) => opt.value)).toEqual(["global-mcp", "mcp"]);
|
||||
|
||||
await user.clear(search);
|
||||
@@ -445,10 +445,17 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expect(getByText("No sections match this search.")).toBeTruthy();
|
||||
});
|
||||
|
||||
// FN-7552: the Authentication section is storage-less (scope: undefined) but belongs to the
|
||||
// Global group in SETTINGS_SECTIONS, so its mobile picker option must still carry the
|
||||
// "Global — " prefix like its Global-group siblings, without changing scoped sibling labels.
|
||||
it("prefixes the storage-less Authentication section with 'Global —' in the mobile picker", async () => {
|
||||
/*
|
||||
FN-7552: the Authentication section is storage-less (scope: undefined) but is global in effect —
|
||||
it holds credentials shared across every project — so its mobile picker option must still read as
|
||||
Global, without changing scoped sibling labels.
|
||||
FNXC:SettingsNavigation 2026-07-15-17:35: the requirement is unchanged; only the notation moved
|
||||
from a "Global — " prefix to a " · Global" suffix, matching the nav labels now that the nav is
|
||||
grouped by topic. FN-7552 originally derived this from Authentication sitting under a group header
|
||||
literally labelled "Global"; no such group exists any more, so SettingsModal names the exception
|
||||
explicitly via STORAGE_LESS_GLOBAL_SECTION_IDS.
|
||||
*/
|
||||
it("marks the storage-less Authentication section as Global in the mobile picker", async () => {
|
||||
mockSettingsViewport(true);
|
||||
const { getByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
@@ -456,9 +463,9 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
const picker = getByLabelText("Settings Section") as HTMLSelectElement;
|
||||
const optionByValue = (value: string) => Array.from(picker.options).find((opt) => opt.value === value);
|
||||
|
||||
expect(optionByValue("authentication")?.textContent).toBe("Global — Authentication");
|
||||
expect(optionByValue("global-mcp")?.textContent).toBe("Global — MCP Servers");
|
||||
expect(optionByValue("mcp")?.textContent).toBe("Project — MCP Servers");
|
||||
expect(optionByValue("authentication")?.textContent).toBe("Authentication · Global");
|
||||
expect(optionByValue("global-mcp")?.textContent).toBe("MCP Servers · Global");
|
||||
expect(optionByValue("mcp")?.textContent).toBe("MCP Servers · Project");
|
||||
});
|
||||
|
||||
it("can open memory settings from the mobile section picker", async () => {
|
||||
@@ -564,30 +571,31 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows scope indicators and updates scope banner across sections", async () => {
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
Scope is communicated per ROW, not by a section banner. The banner claimed one scope for a whole section, which was frequently false — Appearance is a "global" nav entry whose task-presentation toggles are all project-scoped — so it is removed and each row states its own scope.
|
||||
This test kept the surviving requirement (an operator can tell what a setting's scope is) and re-pointed it at the mechanism that now carries it: the nav scope icons plus the per-row badges. The old assertions on `.settings-scope-project` / `.settings-scope-global` banner elements went with the banner.
|
||||
*/
|
||||
it("shows scope on nav items and per-row badges rather than a section banner", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, getByText, getAllByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
const { container, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Authentication is first with no scope banner by default - click the Project-scoped General section
|
||||
expect(container.querySelectorAll(".settings-scope-icon").length).toBeGreaterThan(0);
|
||||
await user.click(getByText("Project General"));
|
||||
|
||||
// Verify project scope banner contains icon elements (SVG from Lucide, not emoji)
|
||||
const projectBanner = container.querySelector(".settings-scope-project");
|
||||
expect(projectBanner).toBeTruthy();
|
||||
const projectBannerIcon = projectBanner!.querySelector(".settings-scope-icon svg");
|
||||
expect(projectBannerIcon).toBeTruthy();
|
||||
expect(getByText("These settings only affect this project.")).toBeTruthy();
|
||||
|
||||
await user.click(getByText("Appearance"));
|
||||
|
||||
// Verify global scope banner contains icon elements (SVG from Lucide, not emoji)
|
||||
const globalBanner = container.querySelector(".settings-scope-global");
|
||||
expect(globalBanner).toBeTruthy();
|
||||
const globalBannerIcon = globalBanner!.querySelector(".settings-scope-icon svg");
|
||||
expect(globalBannerIcon).toBeTruthy();
|
||||
expect(getByText("These settings are shared across all your Fusion projects.")).toBeTruthy();
|
||||
// The banner is gone for good — it asserted a single scope for a section
|
||||
// that genuinely mixes them.
|
||||
expect(container.querySelector(".settings-scope-banner")).toBeNull();
|
||||
expect(container.querySelector(".settings-scope-project")).toBeNull();
|
||||
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||||
|
||||
// Appearance is exactly the mixed case: global theme controls above,
|
||||
// project-scoped task-presentation toggles below, each badged for itself.
|
||||
const badges = container.querySelectorAll('[data-testid="settings-field-row-scope"]');
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
expect(Array.from(badges).map((b) => b.textContent)).toContain("project");
|
||||
});
|
||||
|
||||
it("renders separate Anthropic Authentication controls on mobile", async () => {
|
||||
@@ -668,8 +676,6 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expectMobileRule(css, ".settings-section-heading", "padding: var(--space-md) 0 var(--space-sm);");
|
||||
expectMobileRule(css, ".settings-section-heading", "margin: 0 0 var(--space-sm);");
|
||||
expectMobileRule(css, ".settings-scope-icon", "margin-right: 0;");
|
||||
expectMobileRule(css, ".settings-scope-banner", "margin: 0 var(--space-sm) var(--space-xs);");
|
||||
expectMobileRule(css, ".settings-scope-banner", "padding: var(--space-xs) var(--space-sm);");
|
||||
expectMobileRule(css, ".settings-empty-state", "padding: var(--space-sm);");
|
||||
expectMobileRule(css, ".settings-description", "padding: 0 var(--space-sm);");
|
||||
expectMobileRule(css, ".theme-selector", "padding: 0 var(--space-sm) var(--space-sm);");
|
||||
|
||||
@@ -4,32 +4,131 @@
|
||||
* redesigned SettingsModal and the WorkflowSettingsPanel read identically.
|
||||
* Mirrors the token/class conventions of WorkflowFieldsPanel.css. */
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Every settings row must render from one type scale, because the pre-migration modal styled a text field's label and a toggle's label three different ways inside the same section: `.form-group label` (12px/600/uppercase/muted), a `.settings-content` override narrowing that to 0.72rem, and `.checkbox-label` (13px/500/sentence-case) whose declarations each carried `!important` purely to out-specify the first two.
|
||||
Sizes name shared `--font-size-*` rungs rather than raw values so the scale stays auditable: label = sm, help/error = xs, scope badge = 2xs.
|
||||
Sentence-case at weight 500 is the surviving idiom; the uppercase/muted treatment is dropped because a settings label names its control rather than heading a column.
|
||||
Rows carry no horizontal padding — `.settings-content` already owns the inset, and a second one would double-indent every row.
|
||||
*/
|
||||
|
||||
.settings-field-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) 0;
|
||||
scroll-margin-block: var(--space-xl);
|
||||
}
|
||||
|
||||
.settings-field-row.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Inline rows (booleans) sit the control on the label's line. The help text then hangs beneath BOTH, indented past the control so it aligns with the label it explains rather than with the checkbox — without the indent the copy starts under the box and the column of help text no longer lines up with the column of labels.
|
||||
The indent is derived from the control's own width plus the head gap, so it tracks the checkbox rather than being a magic number.
|
||||
*/
|
||||
.settings-field-row--inline {
|
||||
--settings-inline-control-width: 13px;
|
||||
}
|
||||
|
||||
.settings-field-row--inline .settings-field-row-head {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-field-row--inline .settings-field-row-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-field-row--inline .settings-field-row-error {
|
||||
padding-inline-start: calc(var(--settings-inline-control-width) + var(--space-sm));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Jump-to-field: choosing a search result scrolls that row into view and flags it here, so the eye lands on the right control inside a long section instead of on the section's top.
|
||||
The hold is deliberately longer than any `--duration-*` UI-transition token (those top out at 0.3s, which reads as a flicker on a row the operator is still hunting for), so the timing is a named local property rather than a borrowed token.
|
||||
It must stay a duration-only value: `--transition-*` tokens bundle an easing keyword, and substituting one beside `ease-out` yields two easing functions, which invalidates the declaration and silently resolves to `animation: none` (the failure that froze 14 spinners in FN-5855).
|
||||
The wash paints background and box-shadow only — never a border or padding — so a landing row does not reflow its neighbours by a pixel.
|
||||
`scroll-margin-block` keeps the landed row clear of the section chrome rather than flush beneath it.
|
||||
*/
|
||||
.settings-field-row {
|
||||
--settings-search-match-duration: 1.6s;
|
||||
--settings-search-match-tint: color-mix(in srgb, var(--accent, #7c5cbf) 18%, transparent);
|
||||
}
|
||||
|
||||
.settings-field-row.is-search-match {
|
||||
border-radius: var(--radius-sm);
|
||||
animation: settings-field-row-match var(--settings-search-match-duration) ease-out;
|
||||
}
|
||||
|
||||
@keyframes settings-field-row-match {
|
||||
0%,
|
||||
60% {
|
||||
background: var(--settings-search-match-tint);
|
||||
box-shadow: 0 0 0 var(--space-xs) var(--settings-search-match-tint);
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
box-shadow: 0 0 0 var(--space-xs) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: A reduced-motion operator still needs to see which row matched, so the wash resolves to a static hold instead of being animated away entirely. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-field-row.is-search-match {
|
||||
animation: none;
|
||||
background: var(--settings-search-match-tint);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-16-00:10:
|
||||
`align-items: flex-start` (not center) so a checkbox sits on the label's FIRST line. Centering aligns it to the midpoint of a label that has wrapped to two or three lines, which floats the box down beside the middle of the sentence.
|
||||
`flex-wrap: nowrap` because the head now holds at most two items — the control and the label group — and the group is what absorbs the wrapping. Allowing the row itself to wrap is what stranded the checkbox on its own line.
|
||||
*/
|
||||
.settings-field-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-16-00:10:
|
||||
Label + scope badge + help tip flow as one run of prose: `min-width: 0` lets the group shrink below its content's intrinsic width (a flex item refuses to by default, which is what forces the wrap), and `flex: 1 1 auto` gives it the width left over beside the control.
|
||||
The children stay inline, so the badge and the "?" trail the label's LAST word rather than being pushed onto their own line — and a wrapped second line starts under the first word, not under the checkbox.
|
||||
*/
|
||||
.settings-field-row-labelgroup {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-16-00:10:
|
||||
The group's children are inline, so the head's flex `gap` no longer separates them — it only ever applied between flex items, and the label/badge/tip are now text runs inside one item. Their spacing has to come from margins instead, or the badge renders flush against the label ("Open tasks in the right sidebarPROJECT").
|
||||
Margin-inline-start rather than a gap so the spacing travels with each trailing element when the label wraps: the badge stays attached to the last word rather than to a column edge.
|
||||
*/
|
||||
.settings-field-row-labelgroup > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.settings-field-row-labelgroup > .settings-field-row-scope,
|
||||
.settings-field-row-labelgroup > .settings-help {
|
||||
margin-inline-start: var(--space-xs);
|
||||
}
|
||||
|
||||
.settings-field-row-label {
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-field-row-scope {
|
||||
font-size: 0.6rem;
|
||||
font-size: var(--font-size-2xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
@@ -50,11 +149,17 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Controls hold the 38px min-height the pre-migration `.settings-content` rule gave inputs, so a migrated row keeps its touch target and does not visibly shrink beside a not-yet-migrated one during the rollout.
|
||||
*/
|
||||
.settings-field-row-control > input:not([type="checkbox"]),
|
||||
.settings-field-row-control > select,
|
||||
.settings-field-row-control > textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-field-row-clear {
|
||||
@@ -81,14 +186,9 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-field-row-help {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-field-row-error {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--color-error, #f85149);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
* SettingsFieldRow — the base layout primitive every typed settings row composes
|
||||
* (U8 / KTD-10). It owns nothing about the control itself: callers pass the
|
||||
* control as `children` and this row handles the surrounding chrome — label,
|
||||
* scope badge (global/project), help text, error band, and an optional
|
||||
* "reset to default" clear affordance.
|
||||
* scope badge (global/project), a help affordance (SettingsHelpTip), an error
|
||||
* band, and an optional "reset to default" clear affordance.
|
||||
*
|
||||
* The error band stays inline and is never deferred behind the help tip: a
|
||||
* validation message the operator has to go looking for is a message they will
|
||||
* not see.
|
||||
*
|
||||
* Strings are pre-translated by callers (the descriptor carries label/help), so
|
||||
* this primitive hardcodes no user-facing copy. The only intrinsic string is the
|
||||
@@ -13,6 +17,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useIsSettingHighlighted } from "./SettingsSearchHighlightContext";
|
||||
import { SettingsHelpTip } from "./SettingsHelpTip";
|
||||
import "./SettingsFieldRow.css";
|
||||
|
||||
/** Which authority level a setting is being edited at. `undefined` renders no
|
||||
@@ -36,6 +42,14 @@ export interface SettingsFieldRowProps {
|
||||
clearable?: boolean;
|
||||
/** Invoked when the user presses the clear affordance. */
|
||||
onClear?: () => void;
|
||||
/**
|
||||
* Places the control on the label's line instead of below it.
|
||||
*
|
||||
* FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
* Booleans read as "[x] Setting name", not as a name with a stray checkbox parked underneath it. The default stacked order (label → control → help) is correct for inputs that need their full width, but applying it to a checkbox strands a 13px box on its own line and breaks the scan down the column of labels.
|
||||
* This restores the reading order of the `checkbox-label` markup the migration replaced; only the styling is unified, not the layout semantics.
|
||||
*/
|
||||
inlineControl?: boolean;
|
||||
/** The control element (input/select/textarea/toggle). */
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -49,40 +63,77 @@ export function SettingsFieldRow({
|
||||
disabled,
|
||||
clearable,
|
||||
onClear,
|
||||
inlineControl,
|
||||
children,
|
||||
}: SettingsFieldRowProps) {
|
||||
const { t } = useTranslation("app");
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
`data-settings-key` is the anchor a search result scrolls to. It lives on the row rather than the control because the row is what the operator needs to read — its label, help text, and scope badge — and scrolling to the bare input would put the label above the fold.
|
||||
It is a data attribute rather than a DOM id: `htmlFor`/`id` already carry the key to bind label→control, and a second element claiming the same id would be invalid and would break that binding.
|
||||
*/
|
||||
const isSearchMatch = useIsSettingHighlighted(htmlFor);
|
||||
|
||||
const control = (
|
||||
<div className="settings-field-row-control">
|
||||
{children}
|
||||
{clearable && (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-field-row-clear"
|
||||
aria-label={t("settings.clearToDefault", "Reset to default")}
|
||||
title={t("settings.clearToDefault", "Reset to default")}
|
||||
disabled={disabled}
|
||||
onClick={onClear}
|
||||
>
|
||||
<RotateCcw size={13} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
Help lives behind a "?" beside the label rather than as a paragraph under the control. Rendering every description inline turned dense sections into walls of prose and is what pushed Merge to invent its own "More details" disclosure; one affordance on the shared row replaces that per-section improvisation.
|
||||
The tip sits AFTER the scope badge so the label line reads "Name [scope] ?" — name first, then its qualifiers.
|
||||
The copy itself is not hidden: SettingsHelpTip keeps it in the DOM and in the accessibility tree, so search still matches on help text and assistive tech still reaches it.
|
||||
*/
|
||||
const labelAndScope = (
|
||||
<>
|
||||
<label className="settings-field-row-label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{scope && (
|
||||
<span
|
||||
className={`settings-field-row-scope settings-field-row-scope--${scope}`}
|
||||
data-testid="settings-field-row-scope"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
)}
|
||||
{help && <SettingsHelpTip settingKey={htmlFor}>{help}</SettingsHelpTip>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`settings-field-row${disabled ? " is-disabled" : ""}`}>
|
||||
<div
|
||||
className={`settings-field-row${inlineControl ? " settings-field-row--inline" : ""}${disabled ? " is-disabled" : ""}${isSearchMatch ? " is-search-match" : ""}`}
|
||||
data-settings-key={htmlFor}
|
||||
>
|
||||
{/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Inline rows put the control FIRST in the DOM, not just visually: a checkbox reads "[x] Hide banners", and reordering with CSS alone would leave the tab and screen-reader order saying "Hide banners [x]", which is the wrong sentence.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-16-00:10:
|
||||
The label, scope badge, and help tip are wrapped in ONE group so they wrap as prose rather than as flex items.
|
||||
Without the wrapper the head is a flex row whose items are [checkbox][label][badge][tip]; once the label is too wide to sit beside the checkbox, the WHOLE label wraps to the next line and strands the checkbox alone on its own — observed on a 390px viewport with "Keep task popups on the view where they were opened".
|
||||
With the wrapper, the checkbox is the only sibling flex item and the group takes the remaining width, so the label's TEXT wraps inside it and every continuation line aligns under the first word instead of under the checkbox.
|
||||
*/}
|
||||
<div className="settings-field-row-head">
|
||||
<label className="settings-field-row-label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{scope && (
|
||||
<span
|
||||
className={`settings-field-row-scope settings-field-row-scope--${scope}`}
|
||||
data-testid="settings-field-row-scope"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
)}
|
||||
{inlineControl && control}
|
||||
<div className="settings-field-row-labelgroup">{labelAndScope}</div>
|
||||
</div>
|
||||
<div className="settings-field-row-control">
|
||||
{children}
|
||||
{clearable && (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-field-row-clear"
|
||||
aria-label={t("settings.clearToDefault", "Reset to default")}
|
||||
title={t("settings.clearToDefault", "Reset to default")}
|
||||
disabled={disabled}
|
||||
onClick={onClear}
|
||||
>
|
||||
<RotateCcw size={13} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{help && <p className="settings-field-row-help">{help}</p>}
|
||||
{!inlineControl && control}
|
||||
{error && (
|
||||
<p className="settings-field-row-error" role="alert">
|
||||
{error}
|
||||
|
||||
119
packages/dashboard/app/components/settings/SettingsHelpTip.css
Normal file
119
packages/dashboard/app/components/settings/SettingsHelpTip.css
Normal file
@@ -0,0 +1,119 @@
|
||||
/* SettingsHelpTip — "?" affordance beside a settings label, revealing its help copy. */
|
||||
|
||||
.settings-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
/* Sits on the label's line, so it must not stretch the row's leading. */
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
The trigger is icon-sized but carries a 24px hit area: on a phone an operator taps a ~13px glyph, and anything under ~24px is a coin-flip. Padding grows the target while the negative margin keeps the glyph optically adjacent to the label instead of pushed away by its own hit area.
|
||||
*/
|
||||
.settings-help-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
margin: -6px 0 -6px -4px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.settings-help-trigger:hover,
|
||||
.settings-help-trigger:focus-visible {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-help[data-open="true"] .settings-help-trigger {
|
||||
color: var(--accent, #7c5cbf);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
The bubble is ALWAYS laid out and only fades in. Hiding it with `display:none` / `visibility:hidden` / an sr-only clip would drop the copy from the accessibility tree, breaking the trigger's `aria-describedby` and in-page find — the copy is deferred visually, not removed. `opacity` leaves it in the tree; `pointer-events: none` stops the invisible box from eating clicks meant for the row.
|
||||
Revealing therefore toggles exactly two properties, so the click state and the hover state cannot drift apart in a long duplicated rule.
|
||||
`max-width: min(320px, ...)` clamps against the viewport because the settings modal is near full-bleed on a phone, where a fixed-width bubble on a narrow row would be cut off at the screen edge.
|
||||
*/
|
||||
.settings-help-bubble {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-xs));
|
||||
inset-inline-start: 0;
|
||||
z-index: 20;
|
||||
width: max-content;
|
||||
max-width: min(320px, calc(100vw - var(--space-xl) * 2));
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: start;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
white-space: normal;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 18%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
/* Click/tap — the baseline, and the only interaction a touch device has. */
|
||||
.settings-help[data-open="true"] .settings-help-bubble {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
Hover/focus reveal is gated to real pointers. Touch browsers synthesise `:hover` on tap and hold it until the user taps elsewhere, so an unguarded rule would strand an open bubble on mobile. Click already covers touch; this is the pointer-device convenience only.
|
||||
`:focus-within` keeps it keyboard-reachable — tabbing to the trigger shows the tip without pressing Enter.
|
||||
*/
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.settings-help:hover .settings-help-bubble,
|
||||
.settings-help:focus-within .settings-help-bubble {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* FNXC:SettingsHelp 2026-07-15-21:10: The fade is decoration; a reduced-motion operator gets the same reveal instantly. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-help-bubble {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:55:
|
||||
On a narrow viewport the bubble anchors to the ROW, not to the trigger, and spans the row's width.
|
||||
Anchoring to the trigger fails wherever the trigger sits away from the left edge: the label line reads "Name [scope] ?", so the "?" is often near the right of a 390px screen, and a bubble starting there runs off-screen no matter how tightly `max-width` clamps it. Measured on an iPhone-sized viewport before this rule: the bubble spanned x=338→658 against a 390px screen — 268px of it unreachable.
|
||||
Neutralising `.settings-help`'s own positioning makes the row the nearest positioned ancestor, so `inset-inline: 0` resolves against the full row and the bubble simply cannot be clipped horizontally. It lands under the whole row rather than under the icon, which on a phone reads better anyway.
|
||||
The row selectors cover both idioms: `.settings-field-row` (shared primitive) and `.form-group` (rows that deliberately stay bespoke).
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
.settings-field-row,
|
||||
.settings-content .form-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.settings-help-bubble {
|
||||
inset-inline: 0;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
116
packages/dashboard/app/components/settings/SettingsHelpTip.tsx
Normal file
116
packages/dashboard/app/components/settings/SettingsHelpTip.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* SettingsHelpTip — the help affordance for a settings row.
|
||||
*
|
||||
* FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
* Help copy moved off the row and behind a small "?" beside the label. Rendering every description inline turned dense sections into walls of prose — the median settings help string is ~100 characters and some run past 400 — which is what drove Merge to invent its own "More details" disclosure. One affordance in the shared row replaces that per-section improvisation, so help reads the same way everywhere.
|
||||
* The copy is NOT hidden: it stays in the DOM at all times so assistive tech and in-page find still reach it, and so the settings search index keeps matching on help text (`aria-describedby` points at it from the trigger). Only its VISUAL presentation is deferred.
|
||||
*
|
||||
* FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
* Opens on click AND on hover, deliberately:
|
||||
* - Click/tap is the baseline because it is the only interaction a touch device has. A hover-only tip is invisible on mobile.
|
||||
* - Hover is layered on top for pointer devices only, via `@media (hover: hover) and (pointer: fine)`. Touch browsers emulate `:hover` on tap and leave it stuck until you tap elsewhere, so an unguarded `:hover` rule would strand an open bubble on mobile.
|
||||
* - Focus opens it too, so the tip is reachable by keyboard without a mouse.
|
||||
* Open state is React's; hover/focus are CSS. They cannot disagree because CSS only ever adds reveal conditions — it never has to know the click state.
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import "./SettingsHelpTip.css";
|
||||
|
||||
export interface SettingsHelpTipProps {
|
||||
/**
|
||||
* Pre-translated help copy.
|
||||
*
|
||||
* FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
* `ReactNode`, not `string`: a large share of settings help is not plain prose — it interleaves `t()` fragments with `<code>` (paths, cron shapes, CLI commands) or an external link. Those rows kept hand-rolled `<small>` help precisely because a single-string API could not carry them, which is what split the section into "rows with a help icon" and "rows with a paragraph".
|
||||
* Accepting nodes is what lets every row use the same affordance without rewording operator-facing copy.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/** Stable id for the setting, used to build the bubble's element id. */
|
||||
settingKey?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-22:25:
|
||||
At most one tip is open at a time, enforced by a broadcast rather than by the outside-pointerdown handler alone.
|
||||
Outside-pointerdown covers tapping, but it is not the only way a tip opens: pressing Enter/Space on a focused trigger fires `click` with NO pointer event, so a keyboard operator moving between two tips would leave the first bubble open underneath the second. Observed as two overlapping bubbles on a 390px viewport.
|
||||
A document-level event keeps the tips decoupled — they never need to know about each other or share a context — and costs one listener per tip.
|
||||
*/
|
||||
const SETTINGS_HELP_OPEN_EVENT = "fusion:settings-help-open";
|
||||
|
||||
export function SettingsHelpTip({ children, settingKey }: SettingsHelpTipProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLSpanElement>(null);
|
||||
const reactId = useId();
|
||||
const helpId = `settings-help-${settingKey ?? reactId}`;
|
||||
|
||||
// Close when any other tip announces that it opened.
|
||||
useEffect(() => {
|
||||
const onOtherOpened = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail !== helpId) setOpen(false);
|
||||
};
|
||||
document.addEventListener(SETTINGS_HELP_OPEN_EVENT, onOtherOpened);
|
||||
return () => document.removeEventListener(SETTINGS_HELP_OPEN_EVENT, onOtherOpened);
|
||||
}, [helpId]);
|
||||
|
||||
const toggle = () => {
|
||||
setOpen((wasOpen) => {
|
||||
const next = !wasOpen;
|
||||
if (next) {
|
||||
document.dispatchEvent(new CustomEvent(SETTINGS_HELP_OPEN_EVENT, { detail: helpId }));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
A tip closes on outside pointer-down and on Escape. Without this, tapping another row on mobile leaves the previous bubble open on top of it — there is no pointer-leave on touch to close it.
|
||||
`pointerdown` rather than `click` so the bubble is gone before the next control receives its press, and it never swallows that first tap.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span className="settings-help" data-open={open ? "true" : "false"} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-help-trigger"
|
||||
aria-label={t("settings.help.show", "Show help")}
|
||||
aria-expanded={open}
|
||||
aria-describedby={helpId}
|
||||
data-testid={settingKey ? `settings-help-${settingKey}` : undefined}
|
||||
onClick={toggle}
|
||||
>
|
||||
<HelpCircle size={13} aria-hidden />
|
||||
</button>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:10:
|
||||
`role="note"`, not `role="tooltip"`: a tooltip is expected to be transient and label-like, while this is a persistent description the operator can open and read. It is also always rendered — CSS hides it visually — so `aria-describedby` above resolves whether or not the bubble is on screen.
|
||||
*/}
|
||||
<span id={helpId} role="note" className="settings-help-bubble">
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsHelpTip;
|
||||
@@ -1,5 +1,6 @@
|
||||
/* SettingsNumberRow (U8 / KTD-10) — numeric input control slot. */
|
||||
|
||||
/* FNXC:SettingsStyling 2026-07-15-18:52: Pairs `.input` (the dashboard-wide control appearance) with this width-only modifier — see SettingsTextRow.css for why the shared class is named rather than duplicated. */
|
||||
.settings-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SettingsNumberRow({
|
||||
>
|
||||
<input
|
||||
id={key}
|
||||
className="settings-number"
|
||||
className="input settings-number"
|
||||
type="number"
|
||||
value={value === null || value === undefined ? "" : value}
|
||||
min={min}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Settings search highlight coordination.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* Settings search returns individual settings, not just sections, so choosing a result has to land the operator on one control inside a section that may hold sixty of them. The modal owns which key is highlighted; every `SettingsFieldRow` reads that key and flags itself when it matches.
|
||||
* Context rather than prop-drilling: rows sit an arbitrary depth below the section component (inside cards, disclosures, and fieldsets), and threading a `highlightedKey` prop through all 34 sections would put a search concern into every intermediate component's signature.
|
||||
* The default value is a no-op highlight so a row rendered outside the provider — the WorkflowSettingsPanel reuses these primitives — behaves normally instead of throwing.
|
||||
*/
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface SettingsSearchHighlightValue {
|
||||
/** Setting key currently highlighted by a search result, or null. */
|
||||
highlightedKey: string | null;
|
||||
}
|
||||
|
||||
const SettingsSearchHighlightContext = createContext<SettingsSearchHighlightValue>({
|
||||
highlightedKey: null,
|
||||
});
|
||||
|
||||
export function SettingsSearchHighlightProvider({
|
||||
highlightedKey,
|
||||
children,
|
||||
}: {
|
||||
highlightedKey: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Memoized so the provider does not re-render every consuming row on each
|
||||
// keystroke in the search box; only an actual change of key matters.
|
||||
const value = useMemo(() => ({ highlightedKey }), [highlightedKey]);
|
||||
return (
|
||||
<SettingsSearchHighlightContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsSearchHighlightContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `key` is the setting a search result asked to highlight. */
|
||||
export function useIsSettingHighlighted(key: string | undefined): boolean {
|
||||
const { highlightedKey } = useContext(SettingsSearchHighlightContext);
|
||||
return key !== undefined && key === highlightedKey;
|
||||
}
|
||||
|
||||
export default SettingsSearchHighlightContext;
|
||||
@@ -1,11 +1,20 @@
|
||||
/* SettingsSection (U8 / KTD-10) — titled grouping for settings rows. */
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Section chrome names the same `--font-size-*` rungs as the rows it wraps, replacing the pre-migration mix of raw values across the modal (headings at 14px, descriptions at 13px, and this primitive's own untokenized 0.9rem/0.75rem).
|
||||
Hierarchy is title = base / desc = xs / row label = sm, so a section title outranks the labels beneath it by size and weight together rather than by weight alone — at the old 0.9rem the title and a 0.875rem label were within half a pixel of each other and the grouping read flat.
|
||||
The title is deliberately not `md`: that rung is the modal-level heading, and a section is subordinate to it.
|
||||
`scroll-margin-block` matches the row primitive so search results that target a section land clear of the section chrome.
|
||||
*/
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
scroll-margin-block: var(--space-xl);
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
@@ -20,14 +29,19 @@
|
||||
|
||||
.settings-section-title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: Section descriptions keep the 78ch measure and `pretty` wrapping used for row help text, so both bodies of explanatory copy hold one column width. */
|
||||
.settings-section-desc {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
max-width: 78ch;
|
||||
text-wrap: pretty;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* SettingsSelectRow (U8 / KTD-10) — select control slot. */
|
||||
|
||||
/* FNXC:SettingsStyling 2026-07-15-18:52: Pairs `.select` (the dashboard-wide select appearance, matching `.input`'s padding/border/type) with this width-only modifier — see SettingsTextRow.css. */
|
||||
.settings-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function SettingsSelectRow({
|
||||
>
|
||||
<select
|
||||
id={key}
|
||||
className="settings-select"
|
||||
className="select settings-select"
|
||||
value={value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/* SettingsTextRow (U8 / KTD-10) — single-line text input control slot. */
|
||||
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-18:52:
|
||||
The control carries the dashboard's standard `.input` class alongside `.settings-text`, and the modifier below only sizes it.
|
||||
Reason: `.settings-text` used to be the ONLY class on the control, and it set nothing but width — so every migrated row rendered a browser-default input (no border, no radius, no surface colour, no `6px 10px` padding, no 13px type) sitting beside unmigrated `.input` neighbours. The rows meant to unify settings were themselves the odd ones out.
|
||||
`.input` is reused rather than restyled or duplicated here: it is the dashboard-wide control appearance across ~300 call sites, so naming it keeps settings identical to every other form in the app and leaves one place to retune it. Duplicating its padding/border into settings CSS would drift the moment either side changed.
|
||||
*/
|
||||
.settings-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@ export function SettingsTextRow({
|
||||
error,
|
||||
clearable,
|
||||
}: SettingsTextRowProps) {
|
||||
const { key, label, help, scope, disabled, placeholder } = descriptor;
|
||||
const { key, label, help, scope, disabled, placeholder, type, autoComplete } = descriptor;
|
||||
/*
|
||||
FNXC:SettingsSecurity 2026-07-15-18:52:
|
||||
A `password` row defaults to `autocomplete="off"` so the browser never offers to save or autofill a stored API token. The descriptor can override it, but the default is the safe one — a caller adding a token row cannot leak it by omission.
|
||||
*/
|
||||
const resolvedAutoComplete = autoComplete ?? (type === "password" ? "off" : undefined);
|
||||
return (
|
||||
<SettingsFieldRow
|
||||
htmlFor={key}
|
||||
@@ -37,8 +42,9 @@ export function SettingsTextRow({
|
||||
>
|
||||
<input
|
||||
id={key}
|
||||
className="settings-text"
|
||||
type="text"
|
||||
className="input settings-text"
|
||||
type={type ?? "text"}
|
||||
autoComplete={resolvedAutoComplete}
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* SettingsTextareaRow (U8 / KTD-10) — multi-line text input control slot. */
|
||||
|
||||
/* FNXC:SettingsStyling 2026-07-15-18:52: Pairs `.input` (the dashboard-wide control appearance) with this modifier — see SettingsTextRow.css. `font-family: inherit` stays: `.input` sets --font-primary, and a textarea would otherwise fall back to a monospace UA default. */
|
||||
.settings-textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function SettingsTextareaRow({
|
||||
>
|
||||
<textarea
|
||||
id={key}
|
||||
className="settings-textarea"
|
||||
className="input settings-textarea"
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function SettingsToggleRow({
|
||||
disabled={disabled}
|
||||
clearable={clearable}
|
||||
onClear={() => onChange(null)}
|
||||
inlineControl
|
||||
>
|
||||
<label className="settings-toggle">
|
||||
<input
|
||||
|
||||
@@ -24,8 +24,14 @@ const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
|
||||
"node-sync": "global",
|
||||
"research-global": "global",
|
||||
remote: "global",
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The Source Control pair owns every GitHub/GitLab key, at one scope each. The two ids look like duplicates but are not: the six dual-scope keys (gitlab* plus githubTrackingDefaultRepo) exist in both DEFAULT_GLOBAL_SETTINGS and DEFAULT_PROJECT_SETTINGS, and the disjointness guard below is per-scope precisely so a global fallback and its project override can coexist.
|
||||
*/
|
||||
"source-control-global": "global",
|
||||
// project sections (new for FN-7506)
|
||||
general: "project",
|
||||
"source-control": "project",
|
||||
commands: "project",
|
||||
worktrees: "project",
|
||||
scheduling: "project",
|
||||
@@ -39,6 +45,8 @@ const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
|
||||
};
|
||||
|
||||
const EXPECTED_EXCLUDED_SECTIONS = [
|
||||
// Owns one control, and it is not a settings-blob key (global-concurrency endpoint).
|
||||
"scheduling-global",
|
||||
"secrets",
|
||||
"global-mcp",
|
||||
"mcp",
|
||||
@@ -127,10 +135,6 @@ describe("settings section-keys registry", () => {
|
||||
"commitAuthorEnabled",
|
||||
"commitAuthorName",
|
||||
"directMergeCommitStrategy",
|
||||
"githubAuthMode",
|
||||
"githubAuthToken",
|
||||
"gitlabAuthToken",
|
||||
"gitlabAuthTokenType",
|
||||
"includeTaskIdInCommit",
|
||||
"integrationBranch",
|
||||
"maxAutoMergeRetries",
|
||||
@@ -148,8 +152,70 @@ describe("settings section-keys registry", () => {
|
||||
"testMode",
|
||||
]),
|
||||
);
|
||||
// gitlabEnabled's enable+URL fields are owned by "general" instead, not duplicated here.
|
||||
expect(entry.keys).not.toContain("gitlabEnabled");
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
Merge owns no forge key at all now — auth mode/token and every gitlab* key moved to "source-control" with their controls. Asserting the whole GitHub/GitLab family is absent (not just `gitlabEnabled`) is what pins the consolidation: a key drifting back here would mean a second section is writing it again, which is the duplicate this split removed.
|
||||
*/
|
||||
for (const forgeKey of [
|
||||
"githubAuthMode",
|
||||
"githubAuthToken",
|
||||
"gitlabAuthToken",
|
||||
"gitlabAuthTokenType",
|
||||
"gitlabEnabled",
|
||||
"gitlabInstanceUrl",
|
||||
"gitlabApiBaseUrl",
|
||||
]) {
|
||||
expect(entry.keys).not.toContain(forgeKey);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
Pins the consolidation itself rather than a section's contents: every project-scoped GitHub/GitLab key is owned by "source-control" and by nothing else. The generic disjointness test above only proves no key has two owners at a scope; it would stay green if a key were dropped from the registry entirely, which is exactly what a careless move would do.
|
||||
*/
|
||||
it("source-control owns every project-scoped GitHub/GitLab key, and general no longer does", () => {
|
||||
const entry = getSectionKeyEntry("source-control")!;
|
||||
expect(entry.scope).toBe("project");
|
||||
expect(new Set(entry.keys)).toEqual(
|
||||
new Set([
|
||||
"githubAuthMode",
|
||||
"githubAuthToken",
|
||||
"githubLinkImportedIssuesToTracking",
|
||||
"githubTrackingDedupEnabled",
|
||||
"githubTrackingDefaultRepo",
|
||||
"githubTrackingEnabledByDefault",
|
||||
"gitlabApiBaseUrl",
|
||||
"gitlabAuthToken",
|
||||
"gitlabAuthTokenType",
|
||||
"gitlabEnabled",
|
||||
"gitlabInstanceUrl",
|
||||
]),
|
||||
);
|
||||
|
||||
const generalKeys = getSectionKeyEntry("general")!.keys;
|
||||
for (const forgeKey of entry.keys) {
|
||||
expect(generalKeys).not.toContain(forgeKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("source-control-global owns the global GitLab fallbacks and the global tracking repo", () => {
|
||||
const entry = getSectionKeyEntry("source-control-global")!;
|
||||
expect(entry.scope).toBe("global");
|
||||
expect(new Set(entry.keys)).toEqual(
|
||||
new Set([
|
||||
"githubTrackingDefaultRepo",
|
||||
"gitlabEnabled",
|
||||
"gitlabInstanceUrl",
|
||||
"gitlabApiBaseUrl",
|
||||
"gitlabAuthToken",
|
||||
"gitlabAuthTokenType",
|
||||
]),
|
||||
);
|
||||
|
||||
const globalGeneralKeys = getSectionKeyEntry("global-general")!.keys;
|
||||
for (const forgeKey of entry.keys) {
|
||||
expect(globalGeneralKeys).not.toContain(forgeKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("a representative global section (appearance) maps to its expected owned keys", () => {
|
||||
|
||||
@@ -121,13 +121,20 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
"notificationProviders",
|
||||
]),
|
||||
experimental: new Set(["experimentalFeatures"]),
|
||||
"global-general": new Set([
|
||||
/*
|
||||
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",
|
||||
@@ -417,10 +424,14 @@ export function splitSettingsSave({
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") {
|
||||
/*
|
||||
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 !== "global-general") {
|
||||
if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection !== "source-control-global") {
|
||||
continue;
|
||||
}
|
||||
if (key === "mcpServers" && scopedMcpValues) {
|
||||
@@ -484,8 +495,11 @@ export function splitSettingsSave({
|
||||
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)
|
||||
if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue;
|
||||
if ((key === "gitlabEnabled" || key === "gitlabInstanceUrl" || key === "gitlabApiBaseUrl" || key === "gitlabAuthToken" || key === "gitlabAuthTokenType") && activeSection === "global-general") continue;
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { SETTINGS_SEARCH_ENTRIES } from "../entries";
|
||||
import { rankSettingsSearchResults, scoreSettingsSearchEntry } from "../match";
|
||||
import type { SettingsSearchEntry } from "../types";
|
||||
|
||||
/**
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* The drift guard. "Every setting is searchable" is only true if it is enforced, and the previous design proved it: the index was a hand-written keyword array per nav entry, and settings shipped unfindable until someone complained (Project Models was patched twice — FN-7907, then title-summarization — because "summarize" matched nothing).
|
||||
* This asserts the invariant structurally instead: every descriptor `key` a section renders MUST have an index entry under that section's id. A new setting without one fails the build, so the index cannot silently fall behind the UI.
|
||||
* It reads section sources rather than rendering them: only the active section mounts in the real modal, and rendering all 34 would need each one's props, stores, and network mocks — a harness far more brittle than the invariant it checks. Descriptor keys are literal strings in the JSX, so the source is an honest inventory of what renders.
|
||||
*/
|
||||
|
||||
const SECTIONS_DIR = resolve(__dirname, "../../sections");
|
||||
|
||||
/**
|
||||
* Extracts the descriptor keys a section renders. Matches the established
|
||||
* `descriptor={{ key: "..." }}` idiom every typed row uses (see GeneralSection
|
||||
* and AppearanceSection); `key` is always the first property by convention.
|
||||
*/
|
||||
function extractDescriptorKeys(source: string): string[] {
|
||||
return [...source.matchAll(/descriptor=\{\{\s*key:\s*"([^"]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Section .tsx files, excluding co-located tests and non-section helpers. */
|
||||
function sectionFiles(): string[] {
|
||||
return readdirSync(SECTIONS_DIR)
|
||||
.filter((f) => f.endsWith("Section.tsx") || f.endsWith("Card.tsx"))
|
||||
.filter((f) => !f.includes(".test."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a section source filename to the nav section id its entries must use.
|
||||
* Only sections that render descriptor rows need an entry here; the map is
|
||||
* asserted to cover every migrated file below, so a migration that forgets to
|
||||
* register its id fails rather than being skipped silently.
|
||||
*/
|
||||
const SECTION_FILE_TO_ID: Record<string, string> = {
|
||||
"AppearanceSection.tsx": "appearance",
|
||||
"BackupsSection.tsx": "backups",
|
||||
"CommandsSection.tsx": "commands",
|
||||
"GeneralSection.tsx": "general",
|
||||
"GlobalGeneralSection.tsx": "global-general",
|
||||
"GlobalModelsSection.tsx": "global-models",
|
||||
"MemorySection.tsx": "memory",
|
||||
"MergeSection.tsx": "merge",
|
||||
"NodeRoutingSection.tsx": "node-routing",
|
||||
"NodeSyncSection.tsx": "node-sync",
|
||||
"NotificationsSection.tsx": "notifications",
|
||||
"ProjectModelsSection.tsx": "project-models",
|
||||
"RemoteSection.tsx": "remote",
|
||||
"ResearchGlobalSection.tsx": "research-global",
|
||||
"ResearchProjectSection.tsx": "research-project",
|
||||
"ScheduledEvalsSection.tsx": "scheduled-evals",
|
||||
"SchedulingGlobalSection.tsx": "scheduling-global",
|
||||
"SchedulingSection.tsx": "scheduling",
|
||||
"SourceControlGlobalSection.tsx": "source-control-global",
|
||||
"SourceControlSection.tsx": "source-control",
|
||||
"WorktreesSection.tsx": "worktrees",
|
||||
};
|
||||
|
||||
describe("settings search index", () => {
|
||||
it("indexes every setting rendered by a migrated section", () => {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const [file, sectionId] of Object.entries(SECTION_FILE_TO_ID)) {
|
||||
const source = readFileSync(join(SECTIONS_DIR, file), "utf8");
|
||||
const rendered = extractDescriptorKeys(source);
|
||||
expect(rendered.length, `${file} renders no descriptor rows — is it migrated?`).toBeGreaterThan(0);
|
||||
|
||||
const indexed = new Set(
|
||||
SETTINGS_SEARCH_ENTRIES.filter((e) => e.sectionId === sectionId).map((e) => e.key),
|
||||
);
|
||||
for (const key of rendered) {
|
||||
if (!indexed.has(key)) missing.push(`${sectionId}: ${key} (rendered by ${file})`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`Settings rendered but absent from the search index — operators cannot find them:\n${missing.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not index settings that no section renders", () => {
|
||||
const renderedBySection = new Map<string, Set<string>>();
|
||||
for (const [file, sectionId] of Object.entries(SECTION_FILE_TO_ID)) {
|
||||
const source = readFileSync(join(SECTIONS_DIR, file), "utf8");
|
||||
const keys = renderedBySection.get(sectionId) ?? new Set<string>();
|
||||
for (const key of extractDescriptorKeys(source)) keys.add(key);
|
||||
renderedBySection.set(sectionId, keys);
|
||||
}
|
||||
|
||||
// A stale entry points search at a control that no longer exists: the
|
||||
// result renders, the jump finds no anchor, and nothing happens.
|
||||
const stale = SETTINGS_SEARCH_ENTRIES.filter(
|
||||
(e) => renderedBySection.has(e.sectionId) && !renderedBySection.get(e.sectionId)!.has(e.key),
|
||||
).map((e) => `${e.sectionId}: ${e.key}`);
|
||||
|
||||
expect(stale, `Indexed settings that no section renders:\n${stale.join("\n")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers a section id for every section that renders descriptor rows", () => {
|
||||
const unregistered = sectionFiles()
|
||||
.filter((file) => !(file in SECTION_FILE_TO_ID))
|
||||
.filter((file) => extractDescriptorKeys(readFileSync(join(SECTIONS_DIR, file), "utf8")).length > 0);
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
A section that renders no descriptor rows is legitimately absent from the map — some are dynamic flag lists or bespoke editors with no addressable control, and some rows stay bespoke on purpose (a password field would render unmasked through SettingsTextRow).
|
||||
But the moment a section renders its first descriptor row it MUST register an id, or its settings would be silently exempt from the coverage assertion above — the exact hole this guard closes. No exceptions are carved out here for that reason.
|
||||
*/
|
||||
expect(
|
||||
unregistered,
|
||||
`Sections rendering descriptor rows without a search-index id:\n${unregistered.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps entries unique per section+key", () => {
|
||||
const seen = new Set<string>();
|
||||
const dupes: string[] = [];
|
||||
for (const e of SETTINGS_SEARCH_ENTRIES) {
|
||||
const id = `${e.sectionId}:${e.key}`;
|
||||
if (seen.has(id)) dupes.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
// Duplicates would render the same setting twice in the result list.
|
||||
expect(dupes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings search ranking", () => {
|
||||
const entry: SettingsSearchEntry = {
|
||||
sectionId: "appearance",
|
||||
key: "showCostBadgeOnCards",
|
||||
labelKey: "l",
|
||||
labelFallback: "Show cost badges on task cards",
|
||||
helpKey: "h",
|
||||
helpFallback: "Board cards show derived model cost next to execution time.",
|
||||
keywords: ["spend"],
|
||||
};
|
||||
|
||||
const resolveEnglish = (_key: string, fallback: string) => fallback;
|
||||
|
||||
it("ranks a label hit above a help-text hit", () => {
|
||||
const label = scoreSettingsSearchEntry(entry, "cost badges", entry.labelFallback, entry.helpFallback);
|
||||
const help = scoreSettingsSearchEntry(entry, "execution time", entry.labelFallback, entry.helpFallback);
|
||||
expect(label).not.toBeNull();
|
||||
expect(help).not.toBeNull();
|
||||
expect(label!).toBeLessThan(help!);
|
||||
});
|
||||
|
||||
it("matches help text, which the pre-rewrite keyword index could not", () => {
|
||||
// The FN-7907 class of miss: the word appears in the copy on screen but in
|
||||
// no hand-written keyword list.
|
||||
expect(
|
||||
scoreSettingsSearchEntry(entry, "derived model cost", entry.labelFallback, entry.helpFallback),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches the stored field name so a config-file name finds its control", () => {
|
||||
expect(
|
||||
scoreSettingsSearchEntry(entry, "showcostbadgeoncards", entry.labelFallback, entry.helpFallback),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("matches curated synonyms absent from the copy", () => {
|
||||
expect(scoreSettingsSearchEntry(entry, "spend", entry.labelFallback, entry.helpFallback)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-match", () => {
|
||||
expect(scoreSettingsSearchEntry(entry, "cloudflared", entry.labelFallback, entry.helpFallback)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns nothing for an empty query rather than the whole index", () => {
|
||||
expect(rankSettingsSearchResults([entry], " ", resolveEnglish)).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders results by score then alphabetically for a stable list", () => {
|
||||
const other: SettingsSearchEntry = {
|
||||
sectionId: "appearance",
|
||||
key: "aCostThing",
|
||||
labelKey: "l2",
|
||||
labelFallback: "A cost thing",
|
||||
};
|
||||
const results = rankSettingsSearchResults([entry, other], "cost", resolveEnglish);
|
||||
expect(results.map((r) => r.key)).toEqual(["aCostThing", "showCostBadgeOnCards"]);
|
||||
});
|
||||
|
||||
it("finds the real 'summarize' miss that motivated the rewrite", () => {
|
||||
// FN-7907 / 2026-07-14: operators searched "summarize"; the section's
|
||||
// keyword list did not carry it, so Project Models did not surface.
|
||||
const autoSummarize: SettingsSearchEntry = {
|
||||
sectionId: "project-models",
|
||||
key: "autoSummarizeTitles",
|
||||
labelKey: "settings.projectModels.autoSummarizeLongDescriptionsAsTitles",
|
||||
labelFallback: "Auto-summarize long descriptions as titles",
|
||||
};
|
||||
const results = rankSettingsSearchResults([autoSummarize], "summarize", resolveEnglish);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].sectionId).toBe("project-models");
|
||||
});
|
||||
});
|
||||
63
packages/dashboard/app/components/settings/search/entries.ts
Normal file
63
packages/dashboard/app/components/settings/search/entries.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The settings search index — every searchable setting in the modal.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* Entries are co-located per section (`sections/<Name>Section.search.ts`) and aggregated here rather than declared in one list. Two reasons: a section's settings and its search entries change in the same edit, so keeping them adjacent is what makes the pairing obvious to the next editor; and one shared list would be a merge-conflict funnel while sections are migrated in parallel.
|
||||
* This barrel is the only place that knows the full set. `settings-search-index.test.ts` enforces the invariant that every descriptor `key` rendered by a section appears here under that section's id — a missing entry fails the build rather than silently producing a setting no one can find, which is how the previous hand-curated keyword lists rotted.
|
||||
* Sections absent from this list render no descriptor rows — dynamic flag lists, bespoke editors, CRUD managers, and controls the primitives cannot express safely (a password field would be rendered unmasked by SettingsTextRow, so token rows stay bespoke by design). They are still findable: the nav matches them on their `searchableText` keywords in SettingsModal.tsx. Only sections with addressable controls can offer jump-to-field.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "./types";
|
||||
import { appearanceSearchEntries } from "../sections/AppearanceSection.search";
|
||||
import { backupsSearchEntries } from "../sections/BackupsSection.search";
|
||||
import { commandsSearchEntries } from "../sections/CommandsSection.search";
|
||||
import { generalSearchEntries } from "../sections/GeneralSection.search";
|
||||
import { globalGeneralSearchEntries } from "../sections/GlobalGeneralSection.search";
|
||||
import { globalModelsSearchEntries } from "../sections/GlobalModelsSection.search";
|
||||
import { memorySearchEntries } from "../sections/MemorySection.search";
|
||||
import { mergeSearchEntries } from "../sections/MergeSection.search";
|
||||
import { nodeRoutingSearchEntries } from "../sections/NodeRoutingSection.search";
|
||||
import { nodeSyncSearchEntries } from "../sections/NodeSyncSection.search";
|
||||
import { notificationsSearchEntries } from "../sections/NotificationsSection.search";
|
||||
import { projectModelsSearchEntries } from "../sections/ProjectModelsSection.search";
|
||||
import { remoteSearchEntries } from "../sections/RemoteSection.search";
|
||||
import { researchGlobalSearchEntries } from "../sections/ResearchGlobalSection.search";
|
||||
import { researchProjectSearchEntries } from "../sections/ResearchProjectSection.search";
|
||||
import { scheduledEvalsSearchEntries } from "../sections/ScheduledEvalsSection.search";
|
||||
import { schedulingGlobalSearchEntries } from "../sections/SchedulingGlobalSection.search";
|
||||
import { schedulingSearchEntries } from "../sections/SchedulingSection.search";
|
||||
import { sourceControlGlobalSearchEntries } from "../sections/SourceControlGlobalSection.search";
|
||||
import { sourceControlSearchEntries } from "../sections/SourceControlSection.search";
|
||||
import { worktreesSearchEntries } from "../sections/WorktreesSection.search";
|
||||
|
||||
/**
|
||||
* Flat index of every searchable setting. Order is not significant — results
|
||||
* are ranked and tie-broken alphabetically at query time.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
|
||||
...appearanceSearchEntries,
|
||||
...backupsSearchEntries,
|
||||
...commandsSearchEntries,
|
||||
...generalSearchEntries,
|
||||
...globalGeneralSearchEntries,
|
||||
...globalModelsSearchEntries,
|
||||
...memorySearchEntries,
|
||||
...mergeSearchEntries,
|
||||
...nodeRoutingSearchEntries,
|
||||
...nodeSyncSearchEntries,
|
||||
...notificationsSearchEntries,
|
||||
...projectModelsSearchEntries,
|
||||
...remoteSearchEntries,
|
||||
...researchGlobalSearchEntries,
|
||||
...researchProjectSearchEntries,
|
||||
...scheduledEvalsSearchEntries,
|
||||
...schedulingGlobalSearchEntries,
|
||||
...schedulingSearchEntries,
|
||||
...sourceControlGlobalSearchEntries,
|
||||
...sourceControlSearchEntries,
|
||||
...worktreesSearchEntries,
|
||||
];
|
||||
|
||||
/** Entries owned by one section id. */
|
||||
export function settingsSearchEntriesForSection(sectionId: string): SettingsSearchEntry[] {
|
||||
return SETTINGS_SEARCH_ENTRIES.filter((entry) => entry.sectionId === sectionId);
|
||||
}
|
||||
98
packages/dashboard/app/components/settings/search/match.ts
Normal file
98
packages/dashboard/app/components/settings/search/match.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Settings search matching and ranking.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* Every setting's label AND help text are indexed, which is the whole point of the rewrite: the previous index was a hand-written `searchableText` keyword array per nav entry, and it rotted exactly as you would expect. Project Models accumulated twenty keywords across two separate fixes (FN-7907, then title-summarization on 2026-07-14) because operators searched "summarize" and the section did not surface. Indexing the copy operators actually read removes that maintenance surface.
|
||||
* Matching is substring, not fuzzy: settings vocabulary is short and domain-specific, and fuzzy matching on a 900-entry index surfaces confusing near-misses ("merge" matching "memory") that make the results feel broken. Substring over label+help+keywords covers the real miss cases.
|
||||
* Ranking exists because a query like "model" legitimately hits dozens of settings; label matches must outrank help-text matches, or the result list leads with settings that merely mention the word in passing.
|
||||
*/
|
||||
import type { SettingsSearchEntry, SettingsSearchResult } from "./types";
|
||||
|
||||
/**
|
||||
* Case/whitespace-normalizes a query or candidate for comparison. Uses
|
||||
* locale-aware lowercasing to match the existing settings-search behavior.
|
||||
*/
|
||||
export function normalizeSettingsSearchText(value: string): string {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Score tiers. Lower is better. A label hit always outranks a help hit, and an
|
||||
* exact/prefix label hit outranks a mid-word one, so searching "merge" leads
|
||||
* with the Merge settings rather than with an unrelated control whose help text
|
||||
* happens to mention merging.
|
||||
*/
|
||||
const SCORE_LABEL_EXACT = 0;
|
||||
const SCORE_LABEL_PREFIX = 1;
|
||||
const SCORE_LABEL_SUBSTRING = 2;
|
||||
const SCORE_KEYWORD = 3;
|
||||
const SCORE_KEY = 4;
|
||||
const SCORE_HELP = 5;
|
||||
|
||||
/**
|
||||
* Scores one entry against an already-normalized query, or returns null when it
|
||||
* does not match. `label`/`help` arrive pre-resolved in the active locale.
|
||||
*/
|
||||
export function scoreSettingsSearchEntry(
|
||||
entry: SettingsSearchEntry,
|
||||
query: string,
|
||||
label: string,
|
||||
help: string | undefined,
|
||||
): number | null {
|
||||
const normalizedLabel = normalizeSettingsSearchText(label);
|
||||
if (normalizedLabel === query) return SCORE_LABEL_EXACT;
|
||||
if (normalizedLabel.startsWith(query)) return SCORE_LABEL_PREFIX;
|
||||
if (normalizedLabel.includes(query)) return SCORE_LABEL_SUBSTRING;
|
||||
|
||||
if (entry.keywords?.some((k) => normalizeSettingsSearchText(k).includes(query))) {
|
||||
return SCORE_KEYWORD;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The field name is searchable so an operator who knows a setting from its config file, an export, or a support thread can paste `autoSummarizeTitles` and land on the control. It ranks below prose because it is the developer-facing name, not what the UI calls the setting.
|
||||
*/
|
||||
if (normalizeSettingsSearchText(entry.key).includes(query)) return SCORE_KEY;
|
||||
|
||||
if (help && normalizeSettingsSearchText(help).includes(query)) return SCORE_HELP;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and ranks the index for a query.
|
||||
*
|
||||
* `resolve` maps an i18n key + English fallback to the active locale's string;
|
||||
* callers pass i18next's `t`. Resolution happens here rather than in the index
|
||||
* so results follow a language switch without rebuilding it.
|
||||
*/
|
||||
export function rankSettingsSearchResults(
|
||||
entries: readonly SettingsSearchEntry[],
|
||||
rawQuery: string,
|
||||
resolve: (key: string, fallback: string) => string,
|
||||
): SettingsSearchResult[] {
|
||||
const query = normalizeSettingsSearchText(rawQuery);
|
||||
if (!query) return [];
|
||||
|
||||
const results: SettingsSearchResult[] = [];
|
||||
for (const entry of entries) {
|
||||
const label = resolve(entry.labelKey, entry.labelFallback);
|
||||
const help = entry.helpKey && entry.helpFallback
|
||||
? resolve(entry.helpKey, entry.helpFallback)
|
||||
: undefined;
|
||||
const score = scoreSettingsSearchEntry(entry, query, label, help);
|
||||
if (score === null) continue;
|
||||
results.push({ ...entry, label, help, score });
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Ties break on label so results hold a stable, alphabetical order across keystrokes. Without it the list is in index-declaration order, which reshuffles as entries are added and makes the list appear to jump while the operator is still typing.
|
||||
*/
|
||||
return results.sort((a, b) => a.score - b.score || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/** Section ids owning at least one matching setting, for filtering the nav. */
|
||||
export function matchedSectionIds(results: readonly SettingsSearchResult[]): Set<string> {
|
||||
return new Set(results.map((r) => r.sectionId));
|
||||
}
|
||||
41
packages/dashboard/app/components/settings/search/types.ts
Normal file
41
packages/dashboard/app/components/settings/search/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Settings search index types.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* Search resolves to individual settings, not just sections, so the index is a flat list of every setting the modal can render. One entry = one control the operator can land on.
|
||||
* `key` is load-bearing twice over: it is the settings field name the section's descriptor declares, AND the `data-settings-key` anchor its rendered row carries. That identity is what lets a search result scroll to the exact control, and it is why entries are keyed by field name rather than by i18n key (several settings share help copy, and i18n keys are not unique per control).
|
||||
* Label and help are stored as i18n key + English fallback rather than resolved strings: the index is a module-scope constant evaluated before i18next initializes, and search must match against the operator's active locale, so resolution is deferred to query time.
|
||||
*/
|
||||
|
||||
/** One searchable setting, addressable by `key` within `sectionId`. */
|
||||
export interface SettingsSearchEntry {
|
||||
/** Section id this setting renders in — must match a SETTINGS_SECTIONS id. */
|
||||
sectionId: string;
|
||||
/** Settings field name; also the row's `data-settings-key` scroll anchor. */
|
||||
key: string;
|
||||
/** i18n key for the control's label. */
|
||||
labelKey: string;
|
||||
/** English label, used as the i18n fallback and for the drift guard. */
|
||||
labelFallback: string;
|
||||
/** i18n key for the control's help text, when it has any. */
|
||||
helpKey?: string;
|
||||
/** English help text, used as the i18n fallback. */
|
||||
helpFallback?: string;
|
||||
/**
|
||||
* Synonyms an operator might search that appear nowhere in the control's own
|
||||
* copy. Use sparingly: label and help are indexed automatically, so this is
|
||||
* only for genuine vocabulary gaps (e.g. "hotkeys" for a control whose copy
|
||||
* only ever says "keyboard shortcut"), never for restating the label.
|
||||
*/
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
/** A settings entry ranked against a query, carrying its resolved copy. */
|
||||
export interface SettingsSearchResult extends SettingsSearchEntry {
|
||||
/** Locale-resolved label shown in the result row. */
|
||||
label: string;
|
||||
/** Locale-resolved help, shown as the result's supporting line. */
|
||||
help?: string;
|
||||
/** Lower is better; see rankSettingsSearchResults for the tiers. */
|
||||
score: number;
|
||||
}
|
||||
@@ -26,11 +26,18 @@
|
||||
* 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.
|
||||
* 4. Each key is assigned to exactly ONE canonical owning section per scope,
|
||||
* so every section's reset is scoped to a disjoint key set. A key may
|
||||
* still appear once at each scope when it is genuinely dual-scope (e.g.
|
||||
* `githubTrackingDefaultRepo`); the disjointness guard is per-scope.
|
||||
*
|
||||
* FNXC:SourceControl 2026-07-15-20:30:
|
||||
* Rule 4 used to arbitrate a real UI duplicate rather than a naming overlap:
|
||||
* `gitlabEnabled` was rendered and written from BOTH "general" and "merge", so
|
||||
* the registry awarded it to "general" to keep reset sets disjoint while the
|
||||
* duplicate toggle stayed on screen. Both sections' GitHub/GitLab controls now
|
||||
* live in the "source-control"/"source-control-global" pair, which owns those
|
||||
* keys outright — the arbitration is no longer needed.
|
||||
*/
|
||||
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
|
||||
import { GLOBAL_SECTION_KEYS, MODEL_LANE_KEYS } from "./save-split";
|
||||
@@ -60,16 +67,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
"enabledBuiltinWorkflowIds",
|
||||
"ephemeralAgentsCanCreateTasks",
|
||||
"ephemeralAgentsEnabled",
|
||||
"githubLinkImportedIssuesToTracking",
|
||||
"githubTrackingDedupEnabled",
|
||||
"githubTrackingDefaultRepo",
|
||||
"githubTrackingEnabledByDefault",
|
||||
"sessionAdvisorEnabledByDefault",
|
||||
// gitlabEnabled/gitlabInstanceUrl/gitlabApiBaseUrl's enable+URL fields are
|
||||
// owned here; gitlabAuthToken/gitlabAuthTokenType are owned by "merge".
|
||||
"gitlabApiBaseUrl",
|
||||
"gitlabEnabled",
|
||||
"gitlabInstanceUrl",
|
||||
"mailAutoCleanupDays",
|
||||
"operationalLogRetentionDays",
|
||||
"quickChatButtonMode",
|
||||
@@ -79,6 +77,24 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
"taskPrefix",
|
||||
"workspaceMode",
|
||||
],
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
Every project-scoped GitHub/GitLab key is owned here, by the one section that now renders them all. This entry is what replaced the split ownership the notes above used to describe: `gitlabEnabled`'s toggle was rendered from BOTH "general" and "merge", so the registry had to arbitrarily award the key to "general" to keep the per-section reset sets disjoint — a bookkeeping fix for a UI duplicate. With a single owning section the arbitration is gone.
|
||||
`githubTrackingDefaultRepo` also appears in "source-control-global" (GLOBAL_SECTION_KEYS): it is a real dual-scope key, and the disjointness guard is per-scope, so the project row here and the global row there are two different settings, not a duplicate.
|
||||
*/
|
||||
"source-control": [
|
||||
"githubAuthMode",
|
||||
"githubAuthToken",
|
||||
"githubLinkImportedIssuesToTracking",
|
||||
"githubTrackingDedupEnabled",
|
||||
"githubTrackingDefaultRepo",
|
||||
"githubTrackingEnabledByDefault",
|
||||
"gitlabApiBaseUrl",
|
||||
"gitlabAuthToken",
|
||||
"gitlabAuthTokenType",
|
||||
"gitlabEnabled",
|
||||
"gitlabInstanceUrl",
|
||||
],
|
||||
commands: ["buildCommand", "testCommand"],
|
||||
worktrees: [
|
||||
"executorAllowSiblingBranchRename",
|
||||
@@ -123,12 +139,6 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
"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",
|
||||
@@ -181,6 +191,12 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
* is disabled for these with a documented reason (surfaced in the dialog).
|
||||
*/
|
||||
export const EXCLUDED_RESET_SECTIONS: Record<string, string> = {
|
||||
/*
|
||||
FNXC:SettingsReset 2026-07-15-18:52:
|
||||
scheduling-global owns exactly one control (`globalMaxConcurrent`), and it is not a settings-blob key: it is read and written through the dedicated global-concurrency endpoint, so per-menu reset has nothing here to reset.
|
||||
Listed explicitly rather than left to the unknown-id fallback: an unregistered id is reset-ineligible with NO reason, which renders the dialog without telling the operator why the button is unavailable.
|
||||
*/
|
||||
"scheduling-global": "The global concurrency cap is managed by the global-concurrency endpoint, not the settings form.",
|
||||
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.",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES } from "@fusion/core";
|
||||
import type { AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core";
|
||||
import { AgentPermissionPolicyEditor } from "../../AgentPermissionPolicyEditor";
|
||||
@@ -11,13 +10,10 @@ function toCompleteAgentPermissionRules(rules?: Partial<AgentPermissionPolicyRul
|
||||
return acc;
|
||||
}, {} as AgentPermissionPolicyRules);
|
||||
}
|
||||
export interface AgentPermissionsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
}
|
||||
export function AgentPermissionsSection({ scopeBanner, form, setForm }: AgentPermissionsSectionProps) {
|
||||
export type AgentPermissionsSectionProps = SectionBaseProps;
|
||||
export function AgentPermissionsSection({ form, setForm }: AgentPermissionsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.agentPermissions.agentPermissions", "Agent Permissions")}</h4>
|
||||
<div className="form-group">
|
||||
<small className="settings-muted">{t("settings.agentPermissions.perAgentSettingsOverrideProjectDefaultsEachCategory", "Project defaults apply to permanent agents, ephemeral task workers, and fallback executor workers unless a per-agent override is set. Exact tool rules compose with the legacy ephemeral create-task toggle. Default: unset \u2014 every action category defaults to allow until a category is explicitly restricted.")}</small>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Search entries for the Appearance section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per control the section renders, co-located so a setting and its index entry change in the same edit. `settings-search-index.test.ts` fails the build if a descriptor `key` here and in AppearanceSection.tsx ever diverge, which is what keeps the index honest without anyone maintaining a keyword list by hand.
|
||||
* Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const appearanceSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "appearance",
|
||||
key: "openTasksInRightSidebar",
|
||||
labelKey: "settings.appearance.openTasksInRightSidebar",
|
||||
labelFallback: "Open tasks in the right sidebar",
|
||||
helpKey: "settings.appearance.openTasksInRightSidebarHelp",
|
||||
helpFallback:
|
||||
"When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled.",
|
||||
keywords: ["dock", "right dock", "side panel"],
|
||||
},
|
||||
{
|
||||
sectionId: "appearance",
|
||||
key: "openMobileTasksInPopup",
|
||||
labelKey: "settings.appearance.openMobileTasksInPopup",
|
||||
labelFallback: "Open tasks as popups",
|
||||
helpKey: "settings.appearance.openMobileTasksInPopupHelp",
|
||||
helpFallback:
|
||||
"When enabled, ordinary board task-card, List row/card, and right-dock Tasks-list clicks open the existing movable task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"mobile" is indexed as a keyword rather than left to the copy: the stored key is `openMobileTasksInPopup` and the setting was mobile-only until FN-7945 made it all-viewport, so operators and older docs still call it the mobile popup setting even though the label no longer says it.
|
||||
*/
|
||||
keywords: ["mobile", "floating window", "modal"],
|
||||
},
|
||||
{
|
||||
sectionId: "appearance",
|
||||
key: "taskPopupsBoardListOnly",
|
||||
labelKey: "settings.appearance.taskPopupsBoardListOnly",
|
||||
labelFallback: "Keep task popups on the view where they were opened",
|
||||
helpKey: "settings.appearance.taskPopupsBoardListOnlyHelp",
|
||||
helpFallback:
|
||||
"When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.",
|
||||
keywords: ["popup view attachment", "pin popup"],
|
||||
},
|
||||
{
|
||||
sectionId: "appearance",
|
||||
key: "showCostBadgeOnCards",
|
||||
labelKey: "settings.appearance.showCostBadgeOnCards",
|
||||
labelFallback: "Show cost badges on task cards",
|
||||
helpKey: "settings.appearance.showCostBadgeOnCardsHelp",
|
||||
helpFallback:
|
||||
"Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.",
|
||||
keywords: ["spend", "price", "tokens", "usage"],
|
||||
},
|
||||
{
|
||||
sectionId: "appearance",
|
||||
key: "taskDetailChatFirst",
|
||||
labelKey: "settings.appearance.taskDetailChatFirst",
|
||||
labelFallback: "Open task details with Chat first",
|
||||
helpKey: "settings.appearance.taskDetailChatFirstHelp",
|
||||
helpFallback:
|
||||
"Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.",
|
||||
keywords: ["activity first", "default tab"],
|
||||
},
|
||||
{
|
||||
sectionId: "appearance",
|
||||
key: "sessionBannersHidden",
|
||||
labelKey: "settings.appearance.hideAISessionNotificationBanners",
|
||||
labelFallback: "Hide AI session notification banners",
|
||||
helpKey: "settings.appearance.suppressTheLdquoNeedsYourInputRdquoBanner",
|
||||
helpFallback:
|
||||
"Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed.",
|
||||
keywords: ["needs your input", "toast", "alert"],
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ThemeMode, ColorTheme } from "@fusion/core";
|
||||
import { ThemeSelector } from "../../ThemeSelector";
|
||||
import { LanguageSelector } from "../../LanguageSelector";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
export interface AppearanceSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct: number;
|
||||
@@ -18,10 +17,17 @@ export interface AppearanceSectionProps extends SectionBaseProps {
|
||||
sessionBannersHidden: boolean;
|
||||
setSessionBannersHidden: (hidden: boolean) => void;
|
||||
}
|
||||
export function AppearanceSection({ scopeBanner, form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Rows render through the shared settings primitives rather than hand-rolled `form-group` + `checkbox-label` markup, so this section's labels, help copy, and padding come from the one type scale instead of the three competing label idioms the modal carried before.
|
||||
`.form-group` itself is untouched and still global: 35 non-settings files style forms with it, so the fix is to migrate settings off it, not to restyle it underneath the rest of the dashboard.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Scope badges are per-row because this section genuinely mixes authority levels: theme, color, and font scale are global (DEFAULT_GLOBAL_SETTINGS), while every task-presentation toggle below is project-scoped (DEFAULT_PROJECT_SETTINGS). The nav labels the whole section "global", which is true only of the theme controls, so the badges are what tell an operator which of these travels between projects.
|
||||
*/
|
||||
export function AppearanceSection({ form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
|
||||
<ThemeSelector themeMode={themeMode} colorTheme={colorTheme} dashboardFontScalePct={dashboardFontScalePct} onThemeModeChange={(mode) => {
|
||||
setForm((f) => ({ ...f, themeMode: mode }));
|
||||
@@ -37,52 +43,77 @@ export function AppearanceSection({ scopeBanner, form, setForm, themeMode, color
|
||||
onShadcnCustomColorsChange?.(colors);
|
||||
}}/>
|
||||
<LanguageSelector />
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.openTasksInRightSidebar === true} onChange={(e) => setForm((f) => ({ ...f, openTasksInRightSidebar: e.target.checked }))}/>
|
||||
<span>{t("settings.appearance.openTasksInRightSidebar", "Open tasks in the right sidebar")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.openTasksInRightSidebarHelp", "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/* FNXC:MobileTaskPopups 2026-07-13-00:00 (FN-7945): Keep the stored openMobileTasksInPopup key for compatibility, but present the setting as all-viewport ordinary task popup routing because desktop operators also need the board, List view, or right-dock Tasks list visible behind task detail. */}
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.openMobileTasksInPopup === true} onChange={(e) => setForm((f) => ({ ...f, openMobileTasksInPopup: e.target.checked }))}/>
|
||||
<span>{t("settings.appearance.openMobileTasksInPopup", "Open tasks as popups")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.openMobileTasksInPopupHelp", "When enabled, ordinary board task-card, List row/card, and right-dock Tasks-list clicks open the existing movable task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/* FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 scopes task popups to their opening dashboard view by default. Operators may explicitly disable it for legacy globally shared popups; hidden scoped entries retain geometry and reopen on return. */}
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.taskPopupsBoardListOnly === true} onChange={(e) => setForm((f) => ({ ...f, taskPopupsBoardListOnly: e.target.checked }))}/>
|
||||
<span>{t("settings.appearance.taskPopupsBoardListOnly", "Keep task popups on the view where they were opened")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.taskPopupsBoardListOnlyHelp", "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/* FNXC:TaskCardCostBadge 2026-07-11-12:15: This project setting is opt-in because board cards are already dense; when enabled, only tasks with recorded positive token usage render a read-time derived spend badge. */}
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={form.showCostBadgeOnCards === true} onChange={(e) => setForm((f) => ({ ...f, showCostBadgeOnCards: e.target.checked }))}/>
|
||||
<span>{t("settings.appearance.showCostBadgeOnCards", "Show cost badges on task cards")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.showCostBadgeOnCardsHelp", "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/* FNXC:TaskDetailActivityFirst 2026-06-30-23:59: The project setting is opt-in because task details now default to Activity-first; explicit Activity/Chat/Logs links keep their destination regardless of this checkbox. */}
|
||||
<label className="checkbox-label">
|
||||
<input id="taskDetailChatFirst" type="checkbox" checked={form.taskDetailChatFirst === true} onChange={(e) => setForm((f) => ({ ...f, taskDetailChatFirst: e.target.checked }))}/>
|
||||
<span>{t("settings.appearance.taskDetailChatFirst", "Open task details with Chat first")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.taskDetailChatFirstHelp", "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input id="sessionBannersHidden" type="checkbox" checked={sessionBannersHidden} onChange={(e) => setSessionBannersHidden(e.target.checked)}/>
|
||||
<span>{t("settings.appearance.hideAISessionNotificationBanners", "Hide AI session notification banners")}</span>
|
||||
</label>
|
||||
<small className="form-text text-muted">{t("settings.appearance.suppressTheLdquoNeedsYourInputRdquoBanner", " Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. ")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "openTasksInRightSidebar",
|
||||
label: t("settings.appearance.openTasksInRightSidebar", "Open tasks in the right sidebar"),
|
||||
help: t("settings.appearance.openTasksInRightSidebarHelp", "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.openTasksInRightSidebar === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, openTasksInRightSidebar: v === true }))}
|
||||
/>
|
||||
{/* FNXC:MobileTaskPopups 2026-07-13-00:00 (FN-7945): Keep the stored openMobileTasksInPopup key for compatibility, but present the setting as all-viewport ordinary task popup routing because desktop operators also need the board, List view, or right-dock Tasks list visible behind task detail. */}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "openMobileTasksInPopup",
|
||||
label: t("settings.appearance.openMobileTasksInPopup", "Open tasks as popups"),
|
||||
help: t("settings.appearance.openMobileTasksInPopupHelp", "When enabled, ordinary board task-card, List row/card, and right-dock Tasks-list clicks open the existing movable task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.openMobileTasksInPopup === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, openMobileTasksInPopup: v === true }))}
|
||||
/>
|
||||
{/* FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 scopes task popups to their opening dashboard view by default. Operators may explicitly disable it for legacy globally shared popups; hidden scoped entries retain geometry and reopen on return. */}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "taskPopupsBoardListOnly",
|
||||
label: t("settings.appearance.taskPopupsBoardListOnly", "Keep task popups on the view where they were opened"),
|
||||
help: t("settings.appearance.taskPopupsBoardListOnlyHelp", "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.taskPopupsBoardListOnly === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taskPopupsBoardListOnly: v === true }))}
|
||||
/>
|
||||
{/* FNXC:TaskCardCostBadge 2026-07-11-12:15: This project setting is opt-in because board cards are already dense; when enabled, only tasks with recorded positive token usage render a read-time derived spend badge. */}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "showCostBadgeOnCards",
|
||||
label: t("settings.appearance.showCostBadgeOnCards", "Show cost badges on task cards"),
|
||||
help: t("settings.appearance.showCostBadgeOnCardsHelp", "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.showCostBadgeOnCards === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, showCostBadgeOnCards: v === true }))}
|
||||
/>
|
||||
{/* FNXC:TaskDetailActivityFirst 2026-06-30-23:59: The project setting is opt-in because task details now default to Activity-first; explicit Activity/Chat/Logs links keep their destination regardless of this checkbox. */}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "taskDetailChatFirst",
|
||||
label: t("settings.appearance.taskDetailChatFirst", "Open task details with Chat first"),
|
||||
help: t("settings.appearance.taskDetailChatFirstHelp", "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.taskDetailChatFirst === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taskDetailChatFirst: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
This one carries no scope badge on purpose: it is a browser-local display preference held outside the settings blob (hence the dedicated prop rather than `form`), so it is neither global nor project state and must not claim to travel with either.
|
||||
*/}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "sessionBannersHidden",
|
||||
label: t("settings.appearance.hideAISessionNotificationBanners", "Hide AI session notification banners"),
|
||||
/*
|
||||
FNXC:SettingsCopy 2026-07-15-17:35:
|
||||
Real typographic quotes, not `“`/`”`: React renders this string as text, so the HTML entities printed verbatim on screen. The i18n key name still spells out the old entities — renaming it would churn key parity across six locales for no user-visible gain.
|
||||
*/
|
||||
help: t("settings.appearance.suppressTheLdquoNeedsYourInputRdquoBanner", "Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed."),
|
||||
}}
|
||||
value={sessionBannersHidden}
|
||||
onChange={(v) => setSessionBannersHidden(v === true)}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
export default AppearanceSection;
|
||||
|
||||
@@ -256,6 +256,10 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
|
||||
</div>))}
|
||||
</div>)}
|
||||
</div>)}
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
No `<small>` in this section moved behind the shared "?" help affordance, and none should. This section has no settings rows: it renders provider CARDS, whose `<small>`s are all live state (save progress, key errors, provider loginError, OpenCode refresh status) that must stay visible, plus two section-level blurbs — this one and the onboarding hint below — that describe the panel rather than any one control.
|
||||
*/}
|
||||
<small className="auth-hint">
|
||||
{t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")}
|
||||
</small>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Search entries for the Backups section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per settings control the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's backup-stats panel and "Backup Now" button are deliberately absent — they report and trigger, they do not configure.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const backupsSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "autoBackupEnabled",
|
||||
labelKey: "settings.backups.enableAutomaticDatabaseBackups",
|
||||
labelFallback: " Enable automatic database backups ",
|
||||
helpKey: "settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically",
|
||||
helpFallback:
|
||||
"When enabled, the database is backed up automatically on a schedule. Default: disabled.",
|
||||
keywords: ["sqlite", "snapshot", "restore"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "autoBackupSchedule",
|
||||
labelKey: "settings.backups.backupScheduleCron",
|
||||
labelFallback: "Backup Schedule (Cron)",
|
||||
helpKey: "settings.backups.cronExpressionForBackupTimingDefault02",
|
||||
helpFallback:
|
||||
" Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ",
|
||||
keywords: ["timing", "frequency"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "autoBackupRetention",
|
||||
labelKey: "settings.backups.retentionCount",
|
||||
labelFallback: "Retention Count",
|
||||
helpKey: "settings.backups.numberOfBackupFilesToKeepOldestAre",
|
||||
helpFallback:
|
||||
"Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7.",
|
||||
keywords: ["how many", "prune", "rotation"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "autoBackupDir",
|
||||
labelKey: "settings.backups.backupDirectory",
|
||||
labelFallback: "Backup Directory",
|
||||
helpKey: "settings.backups.directoryForBackupFilesRelativeToProjectRoot",
|
||||
helpFallback:
|
||||
"Directory for backup files, relative to project root. Default: .fusion/backups.",
|
||||
keywords: ["location", "folder", "destination"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "memoryBackupEnabled",
|
||||
labelKey: "settings.backups.enableAutomaticMemoryBackups",
|
||||
labelFallback: " Enable automatic memory backups ",
|
||||
helpKey: "settings.backups.whenEnabledProjectAndAgentMemoryFilesAre",
|
||||
helpFallback:
|
||||
"When enabled, project and agent memory files are backed up automatically on a schedule. Default: disabled.",
|
||||
keywords: ["snapshot", "restore"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "memoryBackupSchedule",
|
||||
labelKey: "settings.backups.memoryBackupScheduleCron",
|
||||
labelFallback: "Memory Backup Schedule (Cron)",
|
||||
helpKey: "settings.backups.cronExpressionForMemoryBackupTimingDefault0",
|
||||
helpFallback:
|
||||
"Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).",
|
||||
keywords: ["timing", "frequency"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "memoryBackupRetention",
|
||||
labelKey: "settings.backups.memoryRetentionCount",
|
||||
labelFallback: "Memory Retention Count",
|
||||
helpKey: "settings.backups.numberOfMemoryBackupsToKeepOldestAre",
|
||||
helpFallback:
|
||||
"Number of memory backups to keep (oldest are deleted first). Range: 1-100. Default: 14.",
|
||||
keywords: ["how many", "prune", "rotation"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "memoryBackupDir",
|
||||
labelKey: "settings.backups.memoryBackupDirectory",
|
||||
labelFallback: "Memory Backup Directory",
|
||||
helpKey: "settings.backups.directoryForMemoryBackupsRelativeToProjectRoot",
|
||||
helpFallback:
|
||||
"Directory for memory backups, relative to project root. Default: .fusion/backups/memory.",
|
||||
keywords: ["location", "folder", "destination"],
|
||||
},
|
||||
{
|
||||
sectionId: "backups",
|
||||
key: "memoryBackupScope",
|
||||
labelKey: "settings.backups.memoryBackupScope",
|
||||
labelFallback: "Memory Backup Scope",
|
||||
helpKey: "settings.backups.memoryBackupScopeHint",
|
||||
helpFallback: "Default: all (project + agents).",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The paths this setting selects between (.fusion/memory, .fusion/agent-memory) live in the option labels, which the index does not read — only the row's label and help. They are keywords so an operator searching a path they saw in the dropdown still lands here.
|
||||
*/
|
||||
keywords: ["agent memory", ".fusion/memory", ".fusion/agent-memory", "what to back up"],
|
||||
},
|
||||
];
|
||||
@@ -1,82 +1,155 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BackupListResponse } from "../../../api";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { LoadingSpinner } from "../../LoadingSpinner";
|
||||
export interface BackupsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
backupInfo: BackupListResponse | null;
|
||||
backupLoading: boolean;
|
||||
onBackupNow: () => void;
|
||||
}
|
||||
export function BackupsSection({ scopeBanner, form, setForm, backupInfo, backupLoading, onBackupNow }: BackupsSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsBackups 2026-07-15-17:35:
|
||||
Every schedule/retention/directory row is gated on its own `*Enabled` toggle: the cron, retention count, and target directory only describe an automatic backup that is actually scheduled, so they are disabled rather than hidden — an operator turning backups on needs to see the values that will take effect.
|
||||
Per-row validation (cron shape, 1-100 retention range, `..` traversal) rides the primitive's `error` band instead of a trailing `field-error` small, so an invalid value reports against the control that owns it.
|
||||
*/
|
||||
export function BackupsSection({ form, setForm, backupInfo, backupLoading, onBackupNow }: BackupsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.backups.databaseBackups", "Database Backups")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupEnabled" className="checkbox-label">
|
||||
<input id="autoBackupEnabled" type="checkbox" checked={form.autoBackupEnabled || false} onChange={(e) => setForm((f) => ({ ...f, autoBackupEnabled: e.target.checked }))}/>{t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups ")}</label>
|
||||
<small>{t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupSchedule">{t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)")}</label>
|
||||
<input id="autoBackupSchedule" type="text" placeholder={t("settings.backups.02", "0 2 * * *")} value={form.autoBackupSchedule || "0 2 * * *"} onChange={(e) => setForm((f) => ({ ...f, autoBackupSchedule: e.target.value }))} disabled={!form.autoBackupEnabled}/>
|
||||
<small>{t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) ")}</small>
|
||||
{form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && (<small className="field-error">{t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")}</small>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupRetention">{t("settings.backups.retentionCount", "Retention Count")}</label>
|
||||
<input id="autoBackupRetention" type="number" min={1} max={100} value={form.autoBackupRetention ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) }));
|
||||
}} disabled={!form.autoBackupEnabled}/>
|
||||
<small>{t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7.")}</small>
|
||||
{form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && (<small className="field-error">{t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")}</small>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoBackupDir">{t("settings.backups.backupDirectory", "Backup Directory")}</label>
|
||||
<input id="autoBackupDir" type="text" placeholder={t("settings.backups.fusionBackups", ".fusion/backups")} value={form.autoBackupDir || ".fusion/backups"} onChange={(e) => setForm((f) => ({ ...f, autoBackupDir: e.target.value }))} disabled={!form.autoBackupEnabled}/>
|
||||
<small>{t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to project root. Default: .fusion/backups.")}</small>
|
||||
{form.autoBackupDir && form.autoBackupDir.includes("..") && (<small className="field-error">{t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")}</small>)}
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "autoBackupEnabled",
|
||||
label: t("settings.backups.enableAutomaticDatabaseBackups", " Enable automatic database backups "),
|
||||
help: t("settings.backups.whenEnabledTheDatabaseIsBackedUpAutomatically", "When enabled, the database is backed up automatically on a schedule. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.autoBackupEnabled || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, autoBackupEnabled: v === true }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "autoBackupSchedule",
|
||||
label: t("settings.backups.backupScheduleCron", "Backup Schedule (Cron)"),
|
||||
help: t("settings.backups.cronExpressionForBackupTimingDefault02", " Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) "),
|
||||
scope: "project",
|
||||
placeholder: t("settings.backups.02", "0 2 * * *"),
|
||||
disabled: !form.autoBackupEnabled,
|
||||
}}
|
||||
value={form.autoBackupSchedule || "0 2 * * *"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, autoBackupSchedule: v ?? "" }))}
|
||||
error={form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule)
|
||||
? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")
|
||||
: undefined}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "autoBackupRetention",
|
||||
label: t("settings.backups.retentionCount", "Retention Count"),
|
||||
help: t("settings.backups.numberOfBackupFilesToKeepOldestAre", "Number of backup files to keep (oldest are deleted first). Range: 1-100. Default: 7."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
max: 100,
|
||||
disabled: !form.autoBackupEnabled,
|
||||
}}
|
||||
value={form.autoBackupRetention ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, autoBackupRetention: v ?? undefined }))}
|
||||
error={form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100)
|
||||
? t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")
|
||||
: undefined}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "autoBackupDir",
|
||||
label: t("settings.backups.backupDirectory", "Backup Directory"),
|
||||
help: t("settings.backups.directoryForBackupFilesRelativeToProjectRoot", "Directory for backup files, relative to project root. Default: .fusion/backups."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.backups.fusionBackups", ".fusion/backups"),
|
||||
disabled: !form.autoBackupEnabled,
|
||||
}}
|
||||
value={form.autoBackupDir || ".fusion/backups"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, autoBackupDir: v ?? "" }))}
|
||||
error={form.autoBackupDir && form.autoBackupDir.includes("..")
|
||||
? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
<h4 className="settings-section-heading">{t("settings.backups.memoryBackups", "Memory Backups")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupEnabled" className="checkbox-label">
|
||||
<input id="memoryBackupEnabled" type="checkbox" checked={form.memoryBackupEnabled || false} onChange={(e) => setForm((f) => ({ ...f, memoryBackupEnabled: e.target.checked }))}/>{t("settings.backups.enableAutomaticMemoryBackups", " Enable automatic memory backups ")}</label>
|
||||
<small>{t("settings.backups.whenEnabledProjectAndAgentMemoryFilesAre", "When enabled, project and agent memory files are backed up automatically on a schedule. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupSchedule">{t("settings.backups.memoryBackupScheduleCron", "Memory Backup Schedule (Cron)")}</label>
|
||||
<input id="memoryBackupSchedule" type="text" placeholder={t("settings.backups.03", "0 3 * * *")} value={form.memoryBackupSchedule || "0 3 * * *"} onChange={(e) => setForm((f) => ({ ...f, memoryBackupSchedule: e.target.value }))} disabled={!form.memoryBackupEnabled}/>
|
||||
<small>{t("settings.backups.cronExpressionForMemoryBackupTimingDefault0", "Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM).")}</small>
|
||||
{form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule) && (<small className="field-error">{t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")}</small>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupRetention">{t("settings.backups.memoryRetentionCount", "Memory Retention Count")}</label>
|
||||
<input id="memoryBackupRetention" type="number" min={1} max={100} value={form.memoryBackupRetention ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, memoryBackupRetention: val === "" ? undefined : Number(val) }));
|
||||
}} disabled={!form.memoryBackupEnabled}/>
|
||||
<small>{t("settings.backups.numberOfMemoryBackupsToKeepOldestAre", "Number of memory backups to keep (oldest are deleted first). Range: 1-100. Default: 14.")}</small>
|
||||
{form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100) && (<small className="field-error">{t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")}</small>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupDir">{t("settings.backups.memoryBackupDirectory", "Memory Backup Directory")}</label>
|
||||
<input id="memoryBackupDir" type="text" placeholder={t("settings.backups.fusionBackupsMemory", ".fusion/backups/memory")} value={form.memoryBackupDir || ".fusion/backups/memory"} onChange={(e) => setForm((f) => ({ ...f, memoryBackupDir: e.target.value }))} disabled={!form.memoryBackupEnabled}/>
|
||||
<small>{t("settings.backups.directoryForMemoryBackupsRelativeToProjectRoot", "Directory for memory backups, relative to project root. Default: .fusion/backups/memory.")}</small>
|
||||
{form.memoryBackupDir && form.memoryBackupDir.includes("..") && (<small className="field-error">{t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")}</small>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackupScope">{t("settings.backups.memoryBackupScope", "Memory Backup Scope")}</label>
|
||||
<select id="memoryBackupScope" value={form.memoryBackupScope || "all"} onChange={(e) => setForm((f) => ({ ...f, memoryBackupScope: e.target.value as "project" | "agents" | "all" }))} disabled={!form.memoryBackupEnabled}>
|
||||
<option value="all">{t("settings.backups.allProjectAgents", "All (project + agents)")}</option>
|
||||
<option value="project">{t("settings.backups.projectOnlyFusionMemory", "Project only (.fusion/memory)")}</option>
|
||||
<option value="agents">{t("settings.backups.agentsOnlyFusionAgentMemory", "Agents only (.fusion/agent-memory)")}</option>
|
||||
</select>
|
||||
<small>{t("settings.backups.memoryBackupScopeHint", "Default: all (project + agents).")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "memoryBackupEnabled",
|
||||
label: t("settings.backups.enableAutomaticMemoryBackups", " Enable automatic memory backups "),
|
||||
help: t("settings.backups.whenEnabledProjectAndAgentMemoryFilesAre", "When enabled, project and agent memory files are backed up automatically on a schedule. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.memoryBackupEnabled || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryBackupEnabled: v === true }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "memoryBackupSchedule",
|
||||
label: t("settings.backups.memoryBackupScheduleCron", "Memory Backup Schedule (Cron)"),
|
||||
help: t("settings.backups.cronExpressionForMemoryBackupTimingDefault0", "Cron expression for memory backup timing. Default: 0 3 * * * (daily at 3 AM)."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.backups.03", "0 3 * * *"),
|
||||
disabled: !form.memoryBackupEnabled,
|
||||
}}
|
||||
value={form.memoryBackupSchedule || "0 3 * * *"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryBackupSchedule: v ?? "" }))}
|
||||
error={form.memoryBackupSchedule && !/^[\s\d*,/-]+$/.test(form.memoryBackupSchedule)
|
||||
? t("settings.backups.invalidCronExpressionFormat", "Invalid cron expression format")
|
||||
: undefined}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "memoryBackupRetention",
|
||||
label: t("settings.backups.memoryRetentionCount", "Memory Retention Count"),
|
||||
help: t("settings.backups.numberOfMemoryBackupsToKeepOldestAre", "Number of memory backups to keep (oldest are deleted first). Range: 1-100. Default: 14."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
max: 100,
|
||||
disabled: !form.memoryBackupEnabled,
|
||||
}}
|
||||
value={form.memoryBackupRetention ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryBackupRetention: v ?? undefined }))}
|
||||
error={form.memoryBackupRetention !== undefined && (form.memoryBackupRetention < 1 || form.memoryBackupRetention > 100)
|
||||
? t("settings.backups.mustBeBetween1And100", "Must be between 1 and 100")
|
||||
: undefined}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "memoryBackupDir",
|
||||
label: t("settings.backups.memoryBackupDirectory", "Memory Backup Directory"),
|
||||
help: t("settings.backups.directoryForMemoryBackupsRelativeToProjectRoot", "Directory for memory backups, relative to project root. Default: .fusion/backups/memory."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.backups.fusionBackupsMemory", ".fusion/backups/memory"),
|
||||
disabled: !form.memoryBackupEnabled,
|
||||
}}
|
||||
value={form.memoryBackupDir || ".fusion/backups/memory"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryBackupDir: v ?? "" }))}
|
||||
error={form.memoryBackupDir && form.memoryBackupDir.includes("..")
|
||||
? t("settings.backups.pathCannotContainParentDirectoryTraversal", "Path cannot contain parent directory traversal (..)")
|
||||
: undefined}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "memoryBackupScope",
|
||||
label: t("settings.backups.memoryBackupScope", "Memory Backup Scope"),
|
||||
help: t("settings.backups.memoryBackupScopeHint", "Default: all (project + agents)."),
|
||||
scope: "project",
|
||||
disabled: !form.memoryBackupEnabled,
|
||||
options: [
|
||||
{ value: "all", label: t("settings.backups.allProjectAgents", "All (project + agents)") },
|
||||
{ value: "project", label: t("settings.backups.projectOnlyFusionMemory", "Project only (.fusion/memory)") },
|
||||
{ value: "agents", label: t("settings.backups.agentsOnlyFusionAgentMemory", "Agents only (.fusion/agent-memory)") },
|
||||
],
|
||||
}}
|
||||
value={form.memoryBackupScope || "all"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryBackupScope: (v ?? "all") as "project" | "agents" | "all" }))}
|
||||
/>
|
||||
{backupLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.backups.loadingBackupInfo", "Loading backup info\u2026")} /></div>) : backupInfo ? (<div className="form-group">
|
||||
<label>{t("settings.backups.currentBackups", "Current Backups")}</label>
|
||||
<div className="backup-stats">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Search entries for the Commands section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per control the section renders, co-located so a setting and its index entry change in the same edit. `settings-search-index.test.ts` fails the build if a descriptor `key` here and in CommandsSection.tsx ever diverge, which is what keeps the index honest without anyone maintaining a keyword list by hand.
|
||||
* Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const commandsSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "commands",
|
||||
key: "testCommand",
|
||||
labelKey: "settings.commands.testCommand",
|
||||
labelFallback: "Test Command",
|
||||
helpKey: "settings.commands.commandUsedToRunTestsInjectedIntoGenerated",
|
||||
helpFallback:
|
||||
"Command used to run tests — injected into generated task specs. No default — unset.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"verify"/"verification" are indexed because this project's own configured test command is `pnpm verify:fast`; operators search the command they run, which the label never says.
|
||||
*/
|
||||
keywords: ["verify", "verification", "vitest", "pnpm test"],
|
||||
},
|
||||
{
|
||||
sectionId: "commands",
|
||||
key: "buildCommand",
|
||||
labelKey: "settings.commands.buildCommand",
|
||||
labelFallback: "Build Command",
|
||||
helpKey: "settings.commands.commandUsedToBuildTheProjectInjectedInto",
|
||||
helpFallback:
|
||||
"Command used to build the project — injected into generated task specs. No default — unset.",
|
||||
keywords: ["compile", "pnpm build"],
|
||||
},
|
||||
];
|
||||
@@ -1,24 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface CommandsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
}
|
||||
export function CommandsSection({ scopeBanner, form, setForm }: CommandsSectionProps) {
|
||||
export type CommandsSectionProps = SectionBaseProps;
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Both commands render through the shared settings primitives instead of hand-rolled `form-group` markup, so label, help, and scope badge come from the one settings type scale. `.form-group` stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath everything else.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Both keys are project-scoped (`DEFAULT_PROJECT_SETTINGS`): a test/build command describes one repository's toolchain and must not follow the operator to another project. The nav already labels this section project-scoped; the badges restate it per row because search can land an operator on a single control with no section chrome in view.
|
||||
*/
|
||||
export function CommandsSection({ form, setForm }: CommandsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.commands.commands", "Commands")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="testCommand">{t("settings.commands.testCommand", "Test Command")}</label>
|
||||
<input id="testCommand" type="text" placeholder={t("settings.commands.eGPnpmTest", "e.g. pnpm test")} value={form.testCommand || ""} onChange={(e) => setForm((f) => ({ ...f, testCommand: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.commands.commandUsedToRunTestsInjectedIntoGenerated", "Command used to run tests \u2014 injected into generated task specs. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="buildCommand">{t("settings.commands.buildCommand", "Build Command")}</label>
|
||||
<input id="buildCommand" type="text" placeholder={t("settings.commands.eGPnpmBuild", "e.g. pnpm build")} value={form.buildCommand || ""} onChange={(e) => setForm((f) => ({ ...f, buildCommand: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.commands.commandUsedToBuildTheProjectInjectedInto", "Command used to build the project \u2014 injected into generated task specs. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
{/* FNXC:Commands 2026-07-15-17:35: An emptied field stores `undefined`, not "", so the key is absent from the settings blob and spec generation omits the command rather than injecting a blank one. */}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "testCommand",
|
||||
label: t("settings.commands.testCommand", "Test Command"),
|
||||
help: t("settings.commands.commandUsedToRunTestsInjectedIntoGenerated", "Command used to run tests — injected into generated task specs. No default — unset."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.commands.eGPnpmTest", "e.g. pnpm test"),
|
||||
}}
|
||||
value={form.testCommand ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, testCommand: v || undefined }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "buildCommand",
|
||||
label: t("settings.commands.buildCommand", "Build Command"),
|
||||
help: t("settings.commands.commandUsedToBuildTheProjectInjectedInto", "Command used to build the project — injected into generated task specs. No default — unset."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.commands.eGPnpmBuild", "e.g. pnpm build"),
|
||||
}}
|
||||
value={form.buildCommand ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, buildCommand: v || undefined }))}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
export default CommandsSection;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface ExperimentalSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
/** Display labels for well-known features (always rendered). */
|
||||
knownFeatures: Record<string, string>;
|
||||
/** Map of legacy alias key -> canonical key. */
|
||||
@@ -14,7 +12,11 @@ export interface ExperimentalSectionProps extends SectionBaseProps {
|
||||
/** Feature keys that are supported internally but should not render as user toggles. */
|
||||
hiddenFeatureKeys?: ReadonlySet<string>;
|
||||
}
|
||||
export function ExperimentalSection({ scopeBanner, form, setForm, knownFeatures, legacyAliases, getCanonicalKey, isFeatureEnabled, hiddenFeatureKeys, }: ExperimentalSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The flag list deliberately stays hand-rolled rather than moving to the shared settings row primitives. A primitive row is addressed by a real settings field name, which doubles as its element id and its search anchor; these rows are sub-keys of the single `experimentalFeatures` record, discovered at runtime from `knownFeatures` plus whatever the stored blob already contains, and labelled from that prop rather than from a fixed `t()` key. There is no stable field name or i18n key to give a descriptor, so the flags are not searchable per-flag and must not pretend to be — the nav's section-level `searchableText` is what finds them.
|
||||
*/
|
||||
export function ExperimentalSection({ form, setForm, knownFeatures, legacyAliases, getCanonicalKey, isFeatureEnabled, hiddenFeatureKeys, }: ExperimentalSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const experimentalFeatures = form.experimentalFeatures ?? {};
|
||||
const allFeatureKeys = Array.from(new Set([
|
||||
@@ -23,7 +25,6 @@ export function ExperimentalSection({ scopeBanner, form, setForm, knownFeatures,
|
||||
])).filter((key) => !hiddenFeatureKeys?.has(key)).sort((a, b) => a.localeCompare(b));
|
||||
const featureFlags = allFeatureKeys.map((key) => [key, isFeatureEnabled(experimentalFeatures, key)] as const);
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.experimental.experimentalFeatures", "Experimental Features")}</h4>
|
||||
<div className="form-group">
|
||||
<small>{t("settings.experimental.experimentalFeaturesAreEarlyCapabilitiesThatAreNot", " Experimental features are early capabilities that are not yet fully stable. Enable them to test new functionality, but be aware they may change or be removed. Default: disabled for every feature flag below. ")}</small>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Search entries for the Project General section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's bespoke rows are deliberately absent — the workflow pickers, built-in workflow enablement list, tracking-repo select, GitLab disclosure, and the Clear-local-data button are not descriptor rows, so they carry no `data-settings-key` anchor for a result to scroll to.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const generalSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "taskPrefix",
|
||||
labelKey: "settings.general.taskPrefix",
|
||||
labelFallback: "Task Prefix",
|
||||
helpKey: "settings.general.prefixForNewTaskIDsEGKB",
|
||||
helpFallback: "Prefix for new task IDs (e.g. KB, PROJ). No default — unset.",
|
||||
keywords: ["task id", "identifier", "naming"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "ephemeralAgentsCanCreateTasks",
|
||||
labelKey: "settings.general.allowEphemeralAgentsToCreateTasks",
|
||||
labelFallback: " Allow ephemeral agents to create tasks ",
|
||||
helpKey: "settings.general.allowEphemeralAgentsToCreateTasksHint",
|
||||
helpFallback:
|
||||
"When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.",
|
||||
keywords: ["follow-up", "permissions"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "workspaceMode",
|
||||
labelKey: "settings.general.workspaceMode",
|
||||
labelFallback: " Workspace mode (multi-repo) ",
|
||||
helpKey: "settings.general.workspaceModeHint",
|
||||
helpFallback:
|
||||
"When enabled, the project root is treated as a workspace containing multiple git sub-repos. Tasks run per-sub-repo and no git repo is created at the root. Disable for single-repo projects. No default — unset (disabled).",
|
||||
keywords: ["monorepo", "polyrepo"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "allowAbsoluteFileBrowserPaths",
|
||||
labelKey: "settings.general.allowAbsoluteFileBrowserPaths",
|
||||
labelFallback: " Allow absolute file-browser paths ",
|
||||
helpKey: "settings.general.allowAbsoluteFileBrowserPathsHint",
|
||||
helpFallback:
|
||||
"When enabled, slash-prefixed paths such as /tmp can be opened in the workspace file browser. Windows drive-letter paths remain blocked, and other path validators are unchanged. Default: disabled.",
|
||||
keywords: ["outside workspace", "root paths"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "quickChatButtonMode",
|
||||
labelKey: "settings.general.quickChatLauncher",
|
||||
labelFallback: "Quick Chat launcher",
|
||||
helpKey: "settings.general.quickChatLauncherHint",
|
||||
helpFallback:
|
||||
"Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden. Default: off (hidden).",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"FAB" is indexed as a keyword rather than left to the copy: the legacy stored key is `showQuickChatFAB`, so operators and older docs still call this the Quick Chat FAB even though the label never says it.
|
||||
*/
|
||||
keywords: ["FAB", "floating action button"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "quickChatCloseOnOutsideClick",
|
||||
labelKey: "settings.general.quickChatCloseOnOutsideClick",
|
||||
labelFallback: "Close Quick Chat on outside click",
|
||||
helpKey: "settings.general.quickChatCloseOnOutsideClickHint",
|
||||
helpFallback:
|
||||
"When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.",
|
||||
keywords: ["dismiss", "backdrop"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "showTaskChatsInCommonFeed",
|
||||
labelKey: "settings.general.showTaskChatsInCommonFeed",
|
||||
labelFallback: "Show task chats in common Chat feed",
|
||||
helpKey: "settings.general.showTaskChatsInCommonFeedHint",
|
||||
helpFallback:
|
||||
"When enabled, populated task-detail Chat conversations appear in the common Direct feed. Empty task chats stay hidden. Default: disabled.",
|
||||
keywords: ["planner chats", "inbox"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "chatAutoCleanupDays",
|
||||
labelKey: "settings.general.autoCleanupOldChats",
|
||||
labelFallback: "Auto-cleanup old chats",
|
||||
helpKey: "settings.general.deleteChatSessionsAndRoomsThatHaveBeen",
|
||||
helpFallback:
|
||||
"Delete chat sessions and rooms that have been idle for this many days. Default: Off.",
|
||||
keywords: ["retention", "prune", "purge"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "mailAutoCleanupDays",
|
||||
labelKey: "settings.general.autoPruneOldMail",
|
||||
labelFallback: "Auto-prune old mail",
|
||||
helpKey: "settings.general.deleteInboxOutboxMessagesOlderThanThisMany",
|
||||
helpFallback:
|
||||
"Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.",
|
||||
keywords: ["retention", "purge", "mailbox"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "operationalLogRetentionDays",
|
||||
labelKey: "settings.general.operationalLogRetention",
|
||||
labelFallback: "Operational log retention",
|
||||
helpKey: "settings.general.loweringThisWindowMeansReliabilityMetricsChartsAnd",
|
||||
helpFallback:
|
||||
" Lowering this window means Reliability metrics/charts and the Activity feed will not show history older than the selected range. Per-task task detail history is unaffected. Default: 30 days. ",
|
||||
keywords: ["run audit", "database size", "purge", "disk"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "chatRoomRecentVerbatimMessages",
|
||||
labelKey: "settings.general.recentVerbatimRoomMessages",
|
||||
labelFallback: "Recent verbatim room messages",
|
||||
helpKey: "settings.general.numberOfMostRecentChatRoomMessagesKept",
|
||||
helpFallback:
|
||||
"Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.",
|
||||
keywords: ["context window", "history depth"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "chatRoomCompactionFetchLimit",
|
||||
labelKey: "settings.general.roomCompactionFetchLimit",
|
||||
labelFallback: "Room compaction fetch limit",
|
||||
helpKey: "settings.general.upperBoundOnMessagesFetchedFromTheRoom",
|
||||
helpFallback:
|
||||
"Upper bound on messages fetched from the room store for compaction consideration. Default: 200.",
|
||||
keywords: ["summarization", "context window"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "chatRoomSummaryMaxChars",
|
||||
labelKey: "settings.general.roomSummaryMaxCharacters",
|
||||
labelFallback: "Room summary max characters",
|
||||
helpKey: "settings.general.hardCapOnTheSynthesizedEarlierRoomContext",
|
||||
helpFallback:
|
||||
'Hard cap on the synthesized "Earlier room context" summary block. Default: 3000.',
|
||||
keywords: ["compaction", "length limit"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "capacityRiskBannerEnabled",
|
||||
labelKey: "settings.general.showCapacityRiskBanner",
|
||||
labelFallback: " Show capacity risk banner ",
|
||||
helpKey: "settings.general.warnOnTheBoardWhenTodoWorkExceeds",
|
||||
helpFallback:
|
||||
"Warn on the board when todo work exceeds the threshold and no idle agents are available. Default: disabled.",
|
||||
keywords: ["backlog warning", "overload", "alert"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "capacityRiskTodoThreshold",
|
||||
labelKey: "settings.general.todoThreshold",
|
||||
labelFallback: "Todo threshold",
|
||||
helpKey: "settings.general.bannerFiresWhenTodoCountIsStrictlyGreater",
|
||||
helpFallback:
|
||||
"Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.",
|
||||
keywords: ["capacity risk", "backlog limit"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "sessionAdvisorEnabledByDefault",
|
||||
labelKey: "settings.general.defaultSessionAdvisorForNewTasks",
|
||||
labelFallback: "Default for new tasks",
|
||||
helpKey: "settings.general.sessionAdvisorHelp",
|
||||
helpFallback:
|
||||
"Controls whether newly created tasks enable the session advisor (live LLM overseer of the executor). Individual tasks can override this from Quick Add or task detail. Also set Session advisor model provider and model id under workflow settings before the advisor can run.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The label is just "Default for new tasks" — it only reads as the session advisor because of the heading above it, which the index does not see. The feature's own names are keywords so a search for "session advisor" reaches the control that turns it on.
|
||||
*/
|
||||
keywords: ["session advisor", "overseer", "oversight", "planner"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "githubImportAutoTranslate",
|
||||
labelKey: "settings.general.autoTranslateImportedIssues",
|
||||
labelFallback: "Auto-translate imported issues",
|
||||
helpKey: "settings.general.autoTranslateImportedIssuesHelp",
|
||||
helpFallback:
|
||||
"When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled.",
|
||||
keywords: ["localization", "foreign language"],
|
||||
},
|
||||
{
|
||||
sectionId: "general",
|
||||
key: "importTranslateTargetLocale",
|
||||
labelKey: "settings.general.translationTargetLanguage",
|
||||
labelFallback: "Translation target language",
|
||||
helpKey: "settings.general.translationTargetLanguageHelp",
|
||||
helpFallback:
|
||||
"Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language.",
|
||||
keywords: ["locale", "localization"],
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type WorkflowDefinition } from "@fusion/core";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
||||
Locale labels come from core's shared `localeDisplayName` (endonyms), NOT from the LanguageSelector component: importing a component module for a constant drags its i18n/react-i18next initialization into every consumer of this section, which breaks tests that mock react-i18next narrowly.
|
||||
@@ -8,24 +12,28 @@ The core helper is the same list the translate banner labels source languages wi
|
||||
import { localeDisplayName } from "@fusion/core/detect-content-language";
|
||||
import { ProjectDefaultWorkflowField } from "../../WorkflowSelector";
|
||||
import { WorkflowIcon } from "../../WorkflowIcon";
|
||||
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
|
||||
import { fetchWorkflows } from "../../../api";
|
||||
import { clearAllLocalCache } from "../../../utils/swrCache";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface GeneralSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
prefixError: string | null;
|
||||
setPrefixError: (value: string | null) => void;
|
||||
projectTrackingRepoOptions: TrackingRepoOption[];
|
||||
projectTrackingRepoLoading: boolean;
|
||||
projectTrackingRepoError: string | null;
|
||||
onQuickChatButtonModeChange?: (mode: "floating" | "footer" | "off") => void;
|
||||
}
|
||||
export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast, prefixError, setPrefixError, projectTrackingRepoOptions, projectTrackingRepoLoading, projectTrackingRepoError, onQuickChatButtonModeChange, }: GeneralSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Plain settings rows render through the shared primitives instead of 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 — so the fix is to migrate settings off it, not to restyle it underneath the rest of the dashboard.
|
||||
Every key here is project-scoped (DEFAULT_PROJECT_SETTINGS), which the per-row badge states: the nav already labels the section "Project General", but the badge is what distinguishes these from the global-tier settings an operator sees one section away.
|
||||
Rows that stay bespoke are the ones a single-string descriptor cannot carry without rewording the copy — help built from `t()` fragments interleaved with `<code>` (ephemeral agents, completion documentation) — plus the custom widgets and editors: the workflow pickers, the built-in workflow enablement list, and the Clear-local-data button.
|
||||
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
GitHub/GitLab settings are NOT in this section. The tracking block, the tracking-repo select, and the GitLab disclosure moved to "Source Control · Project" (SourceControlSection.tsx), which also absorbed Merge's GitHub/GitLab auth blocks. Do not add source-control settings back here: `gitlabEnabled` was previously writable from both this section and Merge, and one owning section is what keeps that from recurring.
|
||||
*/
|
||||
export function GeneralSection({ form, setForm, projectId, addToast, prefixError, setPrefixError, onQuickChatButtonModeChange, }: GeneralSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [builtinWorkflows, setBuiltinWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
useEffect(() => {
|
||||
@@ -118,12 +126,24 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
window.location.reload();
|
||||
};
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.general.general", "General")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="taskPrefix">{t("settings.general.taskPrefix", "Task Prefix")}</label>
|
||||
<input id="taskPrefix" type="text" placeholder={t("settings.general.fN", "FN")} value={form.taskPrefix || ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
A blank prefix stores `undefined`, not "": empty means "no prefix configured" and must delete the
|
||||
key rather than persist an empty string. Validation is advisory — the typed value is stored even
|
||||
while it fails the 1–5 uppercase rule, so the operator keeps editing what they typed.
|
||||
*/}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "taskPrefix",
|
||||
label: t("settings.general.taskPrefix", "Task Prefix"),
|
||||
help: t("settings.general.prefixForNewTaskIDsEGKB", "Prefix for new task IDs (e.g. KB, PROJ). No default — unset."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.general.fN", "FN"),
|
||||
}}
|
||||
value={form.taskPrefix || ""}
|
||||
onChange={(v) => {
|
||||
const val = v ?? "";
|
||||
setForm((f) => ({ ...f, taskPrefix: val || undefined }));
|
||||
if (val && !/^[A-Z]{1,5}$/.test(val)) {
|
||||
setPrefixError(t("settings.general.prefixMustBe15UppercaseLetters", "Prefix must be 1–5 uppercase letters"));
|
||||
@@ -131,10 +151,9 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
else {
|
||||
setPrefixError(null);
|
||||
}
|
||||
}}/>
|
||||
{prefixError && <small className="field-error">{prefixError}</small>}
|
||||
{!prefixError && <small>{t("settings.general.prefixForNewTaskIDsEGKB", "Prefix for new task IDs (e.g. KB, PROJ). No default \u2014 unset.")}</small>}
|
||||
</div>
|
||||
}}
|
||||
error={prefixError ?? undefined}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast}/>
|
||||
<small>{t("settings.general.newTasksInheritThisCustomWorkflowsStepsOverridable", "New tasks inherit this custom workflow's steps (overridable per task). No default \u2014 unset (built-in default workflow).")}</small>
|
||||
@@ -170,11 +189,16 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
|
||||
Default-on toggle controlling whether ephemeral task-worker agents may open new tasks via fn_task_create. Turning it off confines task creation to humans and permanent agents; ephemeral callers get a rejection.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="ephemeralAgentsCanCreateTasks" className="checkbox-label">
|
||||
<input id="ephemeralAgentsCanCreateTasks" type="checkbox" checked={form.ephemeralAgentsCanCreateTasks !== false} onChange={(e) => setForm((f) => ({ ...f, ephemeralAgentsCanCreateTasks: e.target.checked }))}/>{t("settings.general.allowEphemeralAgentsToCreateTasks", " Allow ephemeral agents to create tasks ")}</label>
|
||||
<small>{t("settings.general.allowEphemeralAgentsToCreateTasksHint", "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "ephemeralAgentsCanCreateTasks",
|
||||
label: t("settings.general.allowEphemeralAgentsToCreateTasks", " Allow ephemeral agents to create tasks "),
|
||||
help: t("settings.general.allowEphemeralAgentsToCreateTasksHint", "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.ephemeralAgentsCanCreateTasks !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ephemeralAgentsCanCreateTasks: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:Workspace 2026-06-24-16:00:
|
||||
Workspace mode toggle: when enabled, the project root is treated as a workspace parent
|
||||
@@ -182,20 +206,30 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
per-sub-repo, and git init is skipped at the root. Toggling on triggers detectWorkspaceRepos
|
||||
and persists .fusion/workspace.json; toggling off removes it.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="workspaceMode" className="checkbox-label">
|
||||
<input id="workspaceMode" type="checkbox" checked={form.workspaceMode === true} onChange={(e) => setForm((f) => ({ ...f, workspaceMode: e.target.checked }))}/>{t("settings.general.workspaceMode", " Workspace mode (multi-repo) ")}</label>
|
||||
<small>{t("settings.general.workspaceModeHint", "When enabled, the project root is treated as a workspace containing multiple git sub-repos. Tasks run per-sub-repo and no git repo is created at the root. Disable for single-repo projects. No default \u2014 unset (disabled).")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "workspaceMode",
|
||||
label: t("settings.general.workspaceMode", " Workspace mode (multi-repo) "),
|
||||
help: t("settings.general.workspaceModeHint", "When enabled, the project root is treated as a workspace containing multiple git sub-repos. Tasks run per-sub-repo and no git repo is created at the root. Disable for single-repo projects. No default \u2014 unset (disabled)."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.workspaceMode === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, workspaceMode: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:FileBrowser 2026-06-29-00:00:
|
||||
This project-scoped General toggle is intentionally default-off because slash-prefixed file-browser paths can browse outside the workspace. It only affects workspace file-browser routes and keeps task-local file APIs and other path validators confined.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="allowAbsoluteFileBrowserPaths" className="checkbox-label">
|
||||
<input id="allowAbsoluteFileBrowserPaths" type="checkbox" checked={form.allowAbsoluteFileBrowserPaths === true} onChange={(e) => setForm((f) => ({ ...f, allowAbsoluteFileBrowserPaths: e.target.checked }))}/>{t("settings.general.allowAbsoluteFileBrowserPaths", " Allow absolute file-browser paths ")}</label>
|
||||
<small>{t("settings.general.allowAbsoluteFileBrowserPathsHint", "When enabled, slash-prefixed paths such as /tmp can be opened in the workspace file browser. Windows drive-letter paths remain blocked, and other path validators are unchanged. Default: disabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "allowAbsoluteFileBrowserPaths",
|
||||
label: t("settings.general.allowAbsoluteFileBrowserPaths", " Allow absolute file-browser paths "),
|
||||
help: t("settings.general.allowAbsoluteFileBrowserPathsHint", "When enabled, slash-prefixed paths such as /tmp can be opened in the workspace file browser. Windows drive-letter paths remain blocked, and other path validators are unchanged. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.allowAbsoluteFileBrowserPaths === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, allowAbsoluteFileBrowserPaths: v === true }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="completionDocumentationMode">{t("settings.general.completionDocumentationAutomation", "Completion Documentation Automation")}</label>
|
||||
<select id="completionDocumentationMode" value={form.completionDocumentationMode || "off"} onChange={(e) => setForm((f) => ({
|
||||
@@ -208,175 +242,235 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
</select>
|
||||
<small>{t("settings.general.controlsHowFutureTaskSpecsHandleReleaseNote", " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ")}<code>.changeset</code>{t("settings.general.workflowsOrChangelogModeWhenContributorsShouldUpdate", " workflows, or changelog mode when contributors should update an existing changelog file. Default: off. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="quickChatButtonMode">{t("settings.general.quickChatLauncher", "Quick Chat launcher")}</label>
|
||||
<select id="quickChatButtonMode" className="select" value={form.quickChatButtonMode ?? (form.showQuickChatFAB ? "floating" : "off")} onChange={(e) => setForm((f) => {
|
||||
const mode = e.target.value as "floating" | "footer" | "off";
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
`showQuickChatFAB` is written alongside `quickChatButtonMode` on every change: the legacy boolean
|
||||
is still the fallback this control reads when no mode is stored, so the two must never disagree.
|
||||
The change is also reported synchronously via onQuickChatButtonModeChange so the launcher moves
|
||||
before Save — operators need to see where the button lands while choosing.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "quickChatButtonMode",
|
||||
label: t("settings.general.quickChatLauncher", "Quick Chat launcher"),
|
||||
help: t("settings.general.quickChatLauncherHint", "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden. Default: off (hidden)."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "floating", label: t("settings.general.quickChatLauncherFloating", "Floating button") },
|
||||
{ value: "footer", label: t("settings.general.quickChatLauncherFooter", "Footer button") },
|
||||
{ value: "off", label: t("settings.general.off", "Off") },
|
||||
],
|
||||
}}
|
||||
value={form.quickChatButtonMode ?? (form.showQuickChatFAB ? "floating" : "off")}
|
||||
onChange={(v) => setForm((f) => {
|
||||
const mode = (v ?? "off") as "floating" | "footer" | "off";
|
||||
onQuickChatButtonModeChange?.(mode);
|
||||
return { ...f, quickChatButtonMode: mode, showQuickChatFAB: mode === "floating" };
|
||||
})}>
|
||||
<option value="floating">{t("settings.general.quickChatLauncherFloating", "Floating button")}</option>
|
||||
<option value="footer">{t("settings.general.quickChatLauncherFooter", "Footer button")}</option>
|
||||
<option value="off">{t("settings.general.off", "Off")}</option>
|
||||
</select>
|
||||
<small>{t("settings.general.quickChatLauncherHint", "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden. Default: off (hidden).")}</small>
|
||||
</div>
|
||||
})}
|
||||
/>
|
||||
{/*
|
||||
FNXC:ChatModal 2026-06-28-00:00:
|
||||
Operators need a Settings > General toggle for Quick Chat outside-click dismissal because accidental board clicks can otherwise close active chat context. Default checked preserves the shipped FN-7152 interaction.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="quickChatCloseOnOutsideClick" className="checkbox-label">
|
||||
<input id="quickChatCloseOnOutsideClick" type="checkbox" checked={form.quickChatCloseOnOutsideClick !== false} onChange={(e) => setForm((f) => ({ ...f, quickChatCloseOnOutsideClick: e.target.checked }))}/>{t("settings.general.quickChatCloseOnOutsideClick", "Close Quick Chat on outside click")}</label>
|
||||
<small>{t("settings.general.quickChatCloseOnOutsideClickHint", "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "quickChatCloseOnOutsideClick",
|
||||
label: t("settings.general.quickChatCloseOnOutsideClick", "Close Quick Chat on outside click"),
|
||||
help: t("settings.general.quickChatCloseOnOutsideClickHint", "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.quickChatCloseOnOutsideClick !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, quickChatCloseOnOutsideClick: v === true }))}
|
||||
/>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.chatHistory", "Chat history")}</h4>
|
||||
{/*
|
||||
FNXC:ChatModal 2026-07-01-00:00:
|
||||
Users asked for task-planner chats to stop cluttering the common Direct feed without forcing a new Direct/Rooms/Tasks tab split. Keep the default hidden and expose this project opt-in for operators who want the previous shared-feed behavior.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="showTaskChatsInCommonFeed" className="checkbox-label">
|
||||
<input id="showTaskChatsInCommonFeed" type="checkbox" checked={form.showTaskChatsInCommonFeed === true} onChange={(e) => setForm((f) => ({ ...f, showTaskChatsInCommonFeed: e.target.checked }))}/>{t("settings.general.showTaskChatsInCommonFeed", "Show task chats in common Chat feed")}</label>
|
||||
<small>{t("settings.general.showTaskChatsInCommonFeedHint", "When enabled, populated task-detail Chat conversations appear in the common Direct feed. Empty task chats stay hidden. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="chatAutoCleanupDays">{t("settings.general.autoCleanupOldChats", "Auto-cleanup old chats")}</label>
|
||||
<select id="chatAutoCleanupDays" className="select" value={form.chatAutoCleanupDays ?? 0} onChange={(e) => setForm((f) => ({ ...f, chatAutoCleanupDays: Number(e.target.value) || 0 }))}>
|
||||
<option value={0}>{t("settings.general.off", "Off")}</option>
|
||||
<option value={7}>{t("settings.general.7Days", "7 days")}</option>
|
||||
<option value={14}>{t("settings.general.14Days", "14 days")}</option>
|
||||
<option value={30}>{t("settings.general.30Days", "30 days")}</option>
|
||||
<option value={60}>{t("settings.general.60Days", "60 days")}</option>
|
||||
<option value={90}>{t("settings.general.90Days", "90 days")}</option>
|
||||
</select>
|
||||
<small>{t("settings.general.deleteChatSessionsAndRoomsThatHaveBeen", "Delete chat sessions and rooms that have been idle for this many days. Default: Off.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mailAutoCleanupDays">{t("settings.general.autoPruneOldMail", "Auto-prune old mail")}</label>
|
||||
<select id="mailAutoCleanupDays" className="select" value={form.mailAutoCleanupDays ?? 0} onChange={(e) => setForm((f) => ({ ...f, mailAutoCleanupDays: Number(e.target.value) || 0 }))}>
|
||||
<option value={0}>{t("settings.general.off", "Off")}</option>
|
||||
<option value={7}>{t("settings.general.7Days", "7 days")}</option>
|
||||
<option value={14}>{t("settings.general.14Days", "14 days")}</option>
|
||||
<option value={30}>{t("settings.general.30Days", "30 days")}</option>
|
||||
<option value={60}>{t("settings.general.60Days", "60 days")}</option>
|
||||
<option value={90}>{t("settings.general.90Days", "90 days")}</option>
|
||||
</select>
|
||||
<small>{t("settings.general.deleteInboxOutboxMessagesOlderThanThisMany", "Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="operationalLogRetentionDays">{t("settings.general.operationalLogRetention", "Operational log retention")}</label>
|
||||
<select id="operationalLogRetentionDays" className="select" value={form.operationalLogRetentionDays ?? 30} onChange={(e) => setForm((f) => ({ ...f, operationalLogRetentionDays: Number(e.target.value) || 0 }))}>
|
||||
<option value={0}>{t("settings.general.off", "Off")}</option>
|
||||
<option value={7}>{t("settings.general.7Days", "7 days")}</option>
|
||||
<option value={14}>{t("settings.general.14Days", "14 days")}</option>
|
||||
<option value={30}>{t("settings.general.30Days", "30 days")}</option>
|
||||
<option value={60}>{t("settings.general.60Days", "60 days")}</option>
|
||||
<option value={90}>{t("settings.general.90Days", "90 days")}</option>
|
||||
</select>
|
||||
<small>{t("settings.general.loweringThisWindowMeansReliabilityMetricsChartsAnd", " Lowering this window means Reliability metrics/charts and the Activity feed will not show history older than the selected range. Per-task task detail history is unaffected. Default: 30 days. ")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "showTaskChatsInCommonFeed",
|
||||
label: t("settings.general.showTaskChatsInCommonFeed", "Show task chats in common Chat feed"),
|
||||
help: t("settings.general.showTaskChatsInCommonFeedHint", "When enabled, populated task-detail Chat conversations appear in the common Direct feed. Empty task chats stay hidden. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.showTaskChatsInCommonFeed === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, showTaskChatsInCommonFeed: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
The three retention pickers store a NUMBER of days, not the option string, and collapse every
|
||||
falsy choice to 0 — 0 is the "Off" sentinel these settings read, so an unparseable or empty
|
||||
selection must disable cleanup rather than persist NaN.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "chatAutoCleanupDays",
|
||||
label: t("settings.general.autoCleanupOldChats", "Auto-cleanup old chats"),
|
||||
help: t("settings.general.deleteChatSessionsAndRoomsThatHaveBeen", "Delete chat sessions and rooms that have been idle for this many days. Default: Off."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "0", label: t("settings.general.off", "Off") },
|
||||
{ value: "7", label: t("settings.general.7Days", "7 days") },
|
||||
{ value: "14", label: t("settings.general.14Days", "14 days") },
|
||||
{ value: "30", label: t("settings.general.30Days", "30 days") },
|
||||
{ value: "60", label: t("settings.general.60Days", "60 days") },
|
||||
{ value: "90", label: t("settings.general.90Days", "90 days") },
|
||||
],
|
||||
}}
|
||||
value={String(form.chatAutoCleanupDays ?? 0)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, chatAutoCleanupDays: Number(v) || 0 }))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "mailAutoCleanupDays",
|
||||
label: t("settings.general.autoPruneOldMail", "Auto-prune old mail"),
|
||||
help: t("settings.general.deleteInboxOutboxMessagesOlderThanThisMany", "Delete inbox/outbox messages older than this many days. Default: Off. 7 days is the suggested setting."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "0", label: t("settings.general.off", "Off") },
|
||||
{ value: "7", label: t("settings.general.7Days", "7 days") },
|
||||
{ value: "14", label: t("settings.general.14Days", "14 days") },
|
||||
{ value: "30", label: t("settings.general.30Days", "30 days") },
|
||||
{ value: "60", label: t("settings.general.60Days", "60 days") },
|
||||
{ value: "90", label: t("settings.general.90Days", "90 days") },
|
||||
],
|
||||
}}
|
||||
value={String(form.mailAutoCleanupDays ?? 0)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, mailAutoCleanupDays: Number(v) || 0 }))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "operationalLogRetentionDays",
|
||||
label: t("settings.general.operationalLogRetention", "Operational log retention"),
|
||||
help: t("settings.general.loweringThisWindowMeansReliabilityMetricsChartsAnd", " Lowering this window means Reliability metrics/charts and the Activity feed will not show history older than the selected range. Per-task task detail history is unaffected. Default: 30 days. "),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "0", label: t("settings.general.off", "Off") },
|
||||
{ value: "7", label: t("settings.general.7Days", "7 days") },
|
||||
{ value: "14", label: t("settings.general.14Days", "14 days") },
|
||||
{ value: "30", label: t("settings.general.30Days", "30 days") },
|
||||
{ value: "60", label: t("settings.general.60Days", "60 days") },
|
||||
{ value: "90", label: t("settings.general.90Days", "90 days") },
|
||||
],
|
||||
}}
|
||||
value={String(form.operationalLogRetentionDays ?? 30)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, operationalLogRetentionDays: Number(v) || 0 }))}
|
||||
/>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.chatRooms", "Chat Rooms")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="chatRoomRecentVerbatimMessages">{t("settings.general.recentVerbatimRoomMessages", "Recent verbatim room messages")}</label>
|
||||
<input id="chatRoomRecentVerbatimMessages" type="number" min="1" className="input" placeholder={t("settings.general.25", "25")} value={form.chatRoomRecentVerbatimMessages ?? ""} onChange={(e) => setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: Number(e.target.value) || undefined }))}/>
|
||||
<small>{t("settings.general.numberOfMostRecentChatRoomMessagesKept", "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="chatRoomCompactionFetchLimit">{t("settings.general.roomCompactionFetchLimit", "Room compaction fetch limit")}</label>
|
||||
<input id="chatRoomCompactionFetchLimit" type="number" min="1" className="input" placeholder={t("settings.general.200", "200")} value={form.chatRoomCompactionFetchLimit ?? ""} onChange={(e) => setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: Number(e.target.value) || undefined }))}/>
|
||||
<small>{t("settings.general.upperBoundOnMessagesFetchedFromTheRoom", "Upper bound on messages fetched from the room store for compaction consideration. Default: 200.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="chatRoomSummaryMaxChars">{t("settings.general.roomSummaryMaxCharacters", "Room summary max characters")}</label>
|
||||
<input id="chatRoomSummaryMaxChars" type="number" min="200" className="input" placeholder={t("settings.general.3000", "3000")} value={form.chatRoomSummaryMaxChars ?? ""} onChange={(e) => setForm((f) => ({ ...f, chatRoomSummaryMaxChars: Number(e.target.value) || undefined }))}/>
|
||||
<small>{t("settings.general.hardCapOnTheSynthesizedEarlierRoomContext", "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
Blank and 0 both store `undefined` for the three room-compaction limits: these settings have no
|
||||
"zero" meaning, so an emptied field must fall back to the schema default rather than pin the
|
||||
transcript to zero verbatim messages.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "chatRoomRecentVerbatimMessages",
|
||||
label: t("settings.general.recentVerbatimRoomMessages", "Recent verbatim room messages"),
|
||||
help: t("settings.general.numberOfMostRecentChatRoomMessagesKept", "Number of most-recent chat-room messages kept verbatim in the responder transcript. Older messages are compacted into a summary block. Default: 25."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
placeholder: t("settings.general.25", "25"),
|
||||
}}
|
||||
value={form.chatRoomRecentVerbatimMessages ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, chatRoomRecentVerbatimMessages: v || undefined }))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "chatRoomCompactionFetchLimit",
|
||||
label: t("settings.general.roomCompactionFetchLimit", "Room compaction fetch limit"),
|
||||
help: t("settings.general.upperBoundOnMessagesFetchedFromTheRoom", "Upper bound on messages fetched from the room store for compaction consideration. Default: 200."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
placeholder: t("settings.general.200", "200"),
|
||||
}}
|
||||
value={form.chatRoomCompactionFetchLimit ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, chatRoomCompactionFetchLimit: v || undefined }))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "chatRoomSummaryMaxChars",
|
||||
label: t("settings.general.roomSummaryMaxCharacters", "Room summary max characters"),
|
||||
help: t("settings.general.hardCapOnTheSynthesizedEarlierRoomContext", "Hard cap on the synthesized \"Earlier room context\" summary block. Default: 3000."),
|
||||
scope: "project",
|
||||
min: 200,
|
||||
placeholder: t("settings.general.3000", "3000"),
|
||||
}}
|
||||
value={form.chatRoomSummaryMaxChars ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, chatRoomSummaryMaxChars: v || undefined }))}
|
||||
/>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.capacityRiskBanner", "Capacity Risk Banner")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="capacityRiskBannerEnabled" className="checkbox-label">
|
||||
<input id="capacityRiskBannerEnabled" type="checkbox" checked={form.capacityRiskBannerEnabled === true} onChange={(e) => setForm((f) => ({ ...f, capacityRiskBannerEnabled: e.target.checked }))}/>{t("settings.general.showCapacityRiskBanner", " Show capacity risk banner ")}</label>
|
||||
<small>{t("settings.general.warnOnTheBoardWhenTodoWorkExceeds", "Warn on the board when todo work exceeds the threshold and no idle agents are available. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="capacityRiskTodoThresholdGeneral">{t("settings.general.todoThreshold", "Todo threshold")}</label>
|
||||
<input id="capacityRiskTodoThresholdGeneral" type="number" min={0} className="input" value={form.capacityRiskTodoThreshold ?? 20} onChange={(e) => setForm((f) => ({
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "capacityRiskBannerEnabled",
|
||||
label: t("settings.general.showCapacityRiskBanner", " Show capacity risk banner "),
|
||||
help: t("settings.general.warnOnTheBoardWhenTodoWorkExceeds", "Warn on the board when todo work exceeds the threshold and no idle agents are available. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.capacityRiskBannerEnabled === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, capacityRiskBannerEnabled: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-15-17:35:
|
||||
The threshold is a task COUNT: it is floored at 0 and truncated to a whole number, and an emptied
|
||||
field stores 0 rather than deleting the key, because the banner compares todo count against a
|
||||
concrete number and a fractional or negative threshold has no meaning.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "capacityRiskTodoThreshold",
|
||||
label: t("settings.general.todoThreshold", "Todo threshold"),
|
||||
help: t("settings.general.bannerFiresWhenTodoCountIsStrictlyGreater", "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled."),
|
||||
scope: "project",
|
||||
min: 0,
|
||||
}}
|
||||
value={form.capacityRiskTodoThreshold ?? 20}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
capacityRiskTodoThreshold: e.target.value === ""
|
||||
capacityRiskTodoThreshold: v === null
|
||||
? 0
|
||||
: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
|
||||
}))}/>
|
||||
<small>{t("settings.general.bannerFiresWhenTodoCountIsStrictlyGreater", "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.")}</small>
|
||||
</div>
|
||||
: Math.max(0, Math.trunc(v) || 0),
|
||||
}))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:PlannerOversight 2026-07-14-18:11:
|
||||
Project default for the session advisor (LLM overseer agent). Per-task overrides
|
||||
come from Quick Add (eye icon) and task detail. Provider/model stay under workflow settings.
|
||||
*/}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.sessionAdvisor", "Session advisor (overseer agent)")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="sessionAdvisorMode">{t("settings.general.defaultSessionAdvisorForNewTasks", "Default for new tasks")}</label>
|
||||
<select
|
||||
id="sessionAdvisorMode"
|
||||
className="select"
|
||||
value={form.sessionAdvisorEnabledByDefault ? "new-tasks" : "off"}
|
||||
onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
sessionAdvisorEnabledByDefault: e.target.value === "new-tasks",
|
||||
}))}
|
||||
data-testid="settings-session-advisor-default"
|
||||
>
|
||||
<option value="off">{t("settings.general.offDefault", "Off (default)")}</option>
|
||||
<option value="new-tasks">{t("settings.general.onForNewTasks", "On for new tasks")}</option>
|
||||
</select>
|
||||
<small>
|
||||
{t(
|
||||
{/*
|
||||
FNXC:PlannerOversight 2026-07-15-17:35:
|
||||
The stored setting is the boolean `sessionAdvisorEnabledByDefault`; the two-option picker is only
|
||||
its presentation, so "off"/"new-tasks" must map back to false/true rather than being persisted.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "sessionAdvisorEnabledByDefault",
|
||||
label: t("settings.general.defaultSessionAdvisorForNewTasks", "Default for new tasks"),
|
||||
help: t(
|
||||
"settings.general.sessionAdvisorHelp",
|
||||
"Controls whether newly created tasks enable the session advisor (live LLM overseer of the executor). Individual tasks can override this from Quick Add or task detail. Also set Session advisor model provider and model id under workflow settings before the advisor can run.",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.gitHubTracking", "GitHub Tracking")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="githubTrackingMode">{t("settings.general.defaultTrackingModeForNewTasks", "Default tracking mode for new tasks")}</label>
|
||||
<select id="githubTrackingMode" className="select" value={form.githubTrackingEnabledByDefault ? "new-tasks" : "off"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
|
||||
}))}>
|
||||
<option value="off">{t("settings.general.offDefault", "Off (default)")}</option>
|
||||
<option value="new-tasks">{t("settings.general.onForNewTasks", "On for new tasks")}</option>
|
||||
</select>
|
||||
<small>{t("settings.general.controlsWhetherNewlyCreatedTasksHaveGitHubIssue", " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ")}</small>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-06-22-03:20:
|
||||
Tracking-issue helper copy. The FN-6771 JSX→t() extraction left a raw HTML
|
||||
entity ("'") in this default string. As a t() argument the string is a
|
||||
plain JS value (not JSX-decoded), so the entity rendered verbatim as the
|
||||
literal "'" instead of an apostrophe. Use a real apostrophe so the copy
|
||||
reads correctly in both modal and embedded presentations.
|
||||
*/}
|
||||
<small>{t("settings.general.trackingIssuesUseThisTaskAposSTitle", " Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. ")}{!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault
|
||||
? t("settings.general.enableSummarizationInProjectModelsToConfigureThatModel", " Enable summarization in Project Models to configure that model.")
|
||||
: ""}
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
This checkbox is project-scoped and import-specific: operators can link imported GitHub issues to GitHub tracking without turning tracking on for every new task.
|
||||
*/}
|
||||
<label htmlFor="githubLinkImportedIssuesToTracking" className="checkbox-label">
|
||||
<input id="githubLinkImportedIssuesToTracking" type="checkbox" checked={form.githubLinkImportedIssuesToTracking === true} onChange={(e) => setForm((f) => ({ ...f, githubLinkImportedIssuesToTracking: e.target.checked }))}/>{t("settings.general.alwaysLinkImportedGitHubIssuesToTracking", " Always link imported GitHub issues to GitHub tracking ")}</label>
|
||||
<small>{t("settings.general.whenEnabledImportedGitHubIssuesUseTheirSource", "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled.")}</small>
|
||||
</div>
|
||||
),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "off", label: t("settings.general.offDefault", "Off (default)") },
|
||||
{ value: "new-tasks", label: t("settings.general.onForNewTasks", "On for new tasks") },
|
||||
],
|
||||
}}
|
||||
value={form.sessionAdvisorEnabledByDefault ? "new-tasks" : "off"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
sessionAdvisorEnabledByDefault: v === "new-tasks",
|
||||
}))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The GitHub Tracking controls, the GitLab disclosure, and `githubLinkImportedIssuesToTracking` moved to "Source Control · Project". The two import-translate rows below did NOT: they only affect the Import Tasks panel's rendering of issue text, not how Fusion talks to GitHub/GitLab.
|
||||
The heading stays because those rows still sit under it and it is their existing copy — there is no "issue import" heading string in the catalog, and inventing one here would be new operator-facing text rather than a move.
|
||||
*/}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.gitHubTracking", "GitHub Tracking")}</h4>
|
||||
{/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-16:35:
|
||||
These use the section's native `form-group` + `checkbox-label` / `select` markup rather than the
|
||||
SettingsToggleRow/SettingsSelectRow primitives. Those primitives render a right-aligned toggle
|
||||
SWITCH, which read as a foreign control next to the plain left-of-text checkboxes every other
|
||||
GitHub/import setting in this section uses. Matching the neighbours is the point: a settings
|
||||
section with two different checkbox idioms looks broken regardless of which is nicer in isolation.
|
||||
|
||||
FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
||||
Both controls live beside the other import-scoped GitHub settings because they
|
||||
only ever affect the Import Tasks panel, never ordinary task creation.
|
||||
@@ -388,58 +482,38 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
dashboard language", so an operator who switches the dashboard to Korean gets Korean
|
||||
translations without touching this setting twice.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="githubImportAutoTranslate" className="checkbox-label">
|
||||
<input id="githubImportAutoTranslate" type="checkbox" checked={form.githubImportAutoTranslate === true} onChange={(e) => setForm((f) => ({ ...f, githubImportAutoTranslate: e.target.checked || undefined }))}/>{t("settings.general.autoTranslateImportedIssues", " Auto-translate imported issues ")}</label>
|
||||
<small>{t("settings.general.autoTranslateImportedIssuesHelp", "When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="importTranslateTargetLocale">{t("settings.general.translationTargetLanguage", "Translation target language")}</label>
|
||||
<select id="importTranslateTargetLocale" className="select" data-testid="import-translate-target-locale-select" value={form.importTranslateTargetLocale ?? ""} onChange={(e) => setForm((f) => ({ ...f, importTranslateTargetLocale: isLocale(e.target.value) ? e.target.value : undefined }))}>
|
||||
<option value="">{t("settings.general.followDashboardLanguage", "Follow dashboard language")}</option>
|
||||
{SUPPORTED_LOCALES.map((locale) => (<option key={locale} value={locale}>
|
||||
{localeDisplayName(locale)}
|
||||
</option>))}
|
||||
</select>
|
||||
<small>{t("settings.general.translationTargetLanguageHelp", "Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">{t("settings.general.projectDefaultTrackingRepo", "Project default tracking repo")}</label>
|
||||
<TrackingRepoSelect id="projectGithubTrackingDefaultRepoGeneral" ariaLabel="Project default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={projectTrackingRepoOptions} loading={projectTrackingRepoLoading} error={projectTrackingRepoError ?? undefined} placeholder={t("settings.general.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
|
||||
<small>{t("settings.general.defaultRepoUsedWhenCreatingGitHubIssuesFor", "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="githubTrackingDedupEnabled" className="checkbox-label">
|
||||
<input id="githubTrackingDedupEnabled" type="checkbox" checked={form.githubTrackingDedupEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, githubTrackingDedupEnabled: e.target.checked }))}/>{t("settings.general.searchTheTrackingRepoForLikelyDuplicatesBefore", " Search the tracking repo for likely duplicates before opening a new issue ")}</label>
|
||||
<small>{t("settings.general.whenEnabledFusionChecksOpenAndClosedIssues", " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. Default: enabled. ")}</small>
|
||||
</div>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.gitLabConfiguration", "GitLab Configuration")}</h4>
|
||||
{/*
|
||||
FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
FN-7453 keeps saved GitLab URL settings separate from the active integration switch. The disclosure is collapsed by default to reduce Settings noise; the summary toggle remains reachable without expanding advanced self-managed URL fields.
|
||||
*/}
|
||||
<details className="settings-gitlab-disclosure" data-testid="project-gitlab-configuration-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.general.gitLabConfiguration", "GitLab Configuration")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="gitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="gitlabEnabled" type="checkbox" checked={form.gitlabEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, gitlabEnabled: e.target.checked }))}/>
|
||||
{t("settings.general.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{form.gitlabEnabled === false ? t("settings.general.gitLabDisabledHint", "GitLab API imports, comments, close/reopen, and refresh operations are disabled. Saved URLs and tokens remain stored for re-enable.") : t("settings.general.gitLabEnabledHint", "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com. No default — unset (unset behaves as enabled until explicitly disabled).")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={form.gitlabEnabled === false}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabInstanceUrl">{t("settings.general.gitLabInstanceUrl", "GitLab instance URL")}</label>
|
||||
<input id="gitlabInstanceUrl" className="input" type="url" placeholder="https://gitlab.com" value={form.gitlabInstanceUrl ?? ""} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabInstanceUrl: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.general.gitLabInstanceUrlHint", "Blank uses GitLab.com or the global default. Set an absolute http:// or https:// URL for self-managed GitLab, such as https://gitlab.example.com/gitlab.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabApiBaseUrl">{t("settings.general.gitLabApiBaseUrlOptional", "GitLab API base URL (optional / advanced)")}</label>
|
||||
<input id="gitlabApiBaseUrl" className="input" type="url" placeholder="https://gitlab.com/api/v4" value={form.gitlabApiBaseUrl ?? ""} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabApiBaseUrl: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.general.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "githubImportAutoTranslate",
|
||||
label: t("settings.general.autoTranslateImportedIssues", "Auto-translate imported issues"),
|
||||
help: t("settings.general.autoTranslateImportedIssuesHelp", "When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.githubImportAutoTranslate === true}
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-19:10:
|
||||
Switching OFF must store `undefined`, not `false`, so the key stays absent from the settings blob and keeps inheriting rather than persisting an explicit opt-out (PR #2147's contract, pinned by GeneralSection.importTranslate.test.tsx).
|
||||
`v ?? undefined` does not do that — the toggle emits `false`, and `false ?? undefined` is `false`. Only a cleared row emits null.
|
||||
*/
|
||||
onChange={(v) => setForm((f) => ({ ...f, githubImportAutoTranslate: v === true ? true : undefined }))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "importTranslateTargetLocale",
|
||||
label: t("settings.general.translationTargetLanguage", "Translation target language"),
|
||||
help: t("settings.general.translationTargetLanguageHelp", "Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "", label: t("settings.general.followDashboardLanguage", "Follow dashboard language") },
|
||||
...SUPPORTED_LOCALES.map((locale) => ({ value: locale, label: localeDisplayName(locale) })),
|
||||
],
|
||||
}}
|
||||
value={form.importTranslateTargetLocale ?? ""}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
importTranslateTargetLocale: v && isLocale(v) ? v : undefined,
|
||||
}))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-07-02-00:00:
|
||||
"Clear local data" panel — the user-facing escape hatch when the dashboard runs out of
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Search entries for the Global General section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's bespoke rows are deliberately absent — CliBinaryPanel, the thinking-log pair, the `fn` binary check, and the update-check toggle are not descriptor rows, so they carry no `data-settings-key` anchor for a result to scroll to.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-20:30:
|
||||
* The global GitLab rows and the global tracking-repo select moved to SourceControlGlobalSection.search.ts with their controls; neither was indexed from here before (both were bespoke), so this is a move of the section's contents, not of its entries.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const globalGeneralSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "global-general",
|
||||
key: "dismissModalsOnOutsideClick",
|
||||
labelKey: "settings.globalGeneral.dismissModalsByClickingOutside",
|
||||
labelFallback: " Dismiss modals by clicking outside ",
|
||||
helpKey: "settings.globalGeneral.dismissModalsByClickingOutsideHint",
|
||||
helpFallback:
|
||||
" When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. ",
|
||||
keywords: ["dialog", "backdrop", "accidental close"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-general",
|
||||
key: "persistAgentToolOutput",
|
||||
labelKey: "settings.globalGeneral.saveToolOutputInAgentLogs",
|
||||
labelFallback: " Save tool output in agent logs ",
|
||||
helpKey: "settings.globalGeneral.whenDisabledToolRowsAreStillLoggedBut",
|
||||
helpFallback:
|
||||
" When disabled, tool rows are still logged but detailed tool payloads are omitted. Very large tool payloads may still be clipped even when this stays enabled. Default: disabled. ",
|
||||
keywords: ["persist", "transcript", "disk usage"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-general",
|
||||
key: "updateCheckFrequency",
|
||||
labelKey: "settings.globalGeneral.frequency",
|
||||
labelFallback: "Frequency",
|
||||
helpKey: "settings.globalGeneral.controlsHowOftenTheDashboardReFetchesThe",
|
||||
helpFallback:
|
||||
" Controls how often the dashboard re-fetches the npm registry. Use the version + refresh control in the header to trigger an immediate check at any time. Default: daily. ",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The label is the bare word "Frequency" — it only reads as the update cadence because of the "Updates" heading above it, which the index does not see. The feature's own vocabulary is keyworded so a search for "update check" reaches this control.
|
||||
*/
|
||||
keywords: ["update check", "cadence", "how often", "version check"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-general",
|
||||
key: "autoReloadOnVersionChange",
|
||||
labelKey: "settings.globalGeneral.autoReloadDashboardOnVersionChange",
|
||||
labelFallback: " Auto-reload dashboard on version change ",
|
||||
helpKey: "settings.globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen",
|
||||
helpFallback:
|
||||
" When enabled (default), the dashboard automatically reloads when it detects a new build version — either from server rebuilds or service worker updates. Disable this to stay on the current version until you manually refresh. Default: enabled. ",
|
||||
keywords: ["refresh", "service worker", "hot reload"],
|
||||
},
|
||||
];
|
||||
@@ -1,80 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { GlobalSettings } from "@fusion/core";
|
||||
import { resolvePersistAgentThinkingLog } from "@fusion/core";
|
||||
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
|
||||
import { CliBinaryPanel } from "../../CliBinaryPanel";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface GlobalGeneralSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
globalSettings: Pick<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType"> | null;
|
||||
onGlobalGitlabSettingsChange: (patch: Partial<Pick<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType">>) => void;
|
||||
globalTrackingRepoOptions: TrackingRepoOption[];
|
||||
globalTrackingRepoLoading: boolean;
|
||||
globalTrackingRepoError: string | null;
|
||||
}
|
||||
export function GlobalGeneralSection({ scopeBanner, form, setForm, globalSettings, onGlobalGitlabSettingsChange, globalTrackingRepoOptions, globalTrackingRepoLoading, globalTrackingRepoError, }: GlobalGeneralSectionProps) {
|
||||
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 `<code>` tags. The thinking-log pair additionally shares ONE help string across two checkboxes, which no per-row descriptor models. CliBinaryPanel is a custom widget.
|
||||
*/
|
||||
export function GlobalGeneralSection({ form, setForm }: GlobalGeneralSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const globalGitlab = globalSettings ?? form;
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.globalGeneral.general", "General")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGithubTrackingDefaultRepo">{t("settings.globalGeneral.globalDefaultTrackingRepo", "Global default tracking repo")}</label>
|
||||
<TrackingRepoSelect id="globalGithubTrackingDefaultRepo" ariaLabel="Global default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={globalTrackingRepoOptions} loading={globalTrackingRepoLoading} error={globalTrackingRepoError ?? undefined} placeholder={t("settings.globalGeneral.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
|
||||
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo. No default — unset.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
FN-7453 adds a global GitLab enable fallback that can disable outbound GitLab HTTP API operations without deleting saved self-managed URL or token settings. Projects can override the enabled state when they need GitLab active while the global fallback is off.
|
||||
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.
|
||||
*/}
|
||||
<details className="settings-gitlab-disclosure" data-testid="global-gitlab-configuration-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.globalGeneral.gitLabConfiguration", "GitLab Configuration")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="globalGitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="globalGitlabEnabled" type="checkbox" checked={globalGitlab.gitlabEnabled !== false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabEnabled: e.target.checked })}/>
|
||||
{t("settings.globalGeneral.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{globalGitlab.gitlabEnabled === false ? t("settings.globalGeneral.gitLabDisabledHint", "GitLab API operations are disabled by global default. Saved URL and token fallbacks remain stored for re-enable.") : t("settings.globalGeneral.gitLabEnabledHint", "Global GitLab URL and token fallbacks apply to projects that do not set their own values. No default — unset (unset behaves as enabled until explicitly disabled).")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={globalGitlab.gitlabEnabled === false}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabInstanceUrl">{t("settings.globalGeneral.gitLabInstanceUrl", "Global GitLab instance URL")}</label>
|
||||
<input id="globalGitlabInstanceUrl" className="input" type="url" placeholder="https://gitlab.com" value={globalGitlab.gitlabInstanceUrl ?? ""} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabInstanceUrl: e.target.value || undefined })}/>
|
||||
<small>{t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value. No default — unset.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabApiBaseUrl">{t("settings.globalGeneral.gitLabApiBaseUrlOptional", "Global GitLab API base URL (optional / advanced)")}</label>
|
||||
<input id="globalGitlabApiBaseUrl" className="input" type="url" placeholder="https://gitlab.com/api/v4" value={globalGitlab.gitlabApiBaseUrl ?? ""} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabApiBaseUrl: e.target.value || undefined })}/>
|
||||
<small>{t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL. No default — unset.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabAuthTokenType">{t("settings.globalGeneral.gitLabTokenType", "Global GitLab token type")}</label>
|
||||
<select id="globalGitlabAuthTokenType" className="select" value={globalGitlab.gitlabAuthTokenType ?? "personal"} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabAuthTokenType: e.target.value as "personal" | "project" | "group" })}>
|
||||
<option value="personal">{t("settings.globalGeneral.gitLabPersonalAccessToken", "Personal access token")}</option>
|
||||
<option value="project">{t("settings.globalGeneral.gitLabProjectAccessToken", "Project access token")}</option>
|
||||
<option value="group">{t("settings.globalGeneral.gitLabGroupAccessToken", "Group access token")}</option>
|
||||
</select>
|
||||
<small>{t("settings.globalGeneral.gitLabTokenTypeHint", "No default — unset (the selector falls back to personal access token until you choose otherwise).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGitlabAuthToken">{t("settings.globalGeneral.gitLabAccessToken", "Global GitLab access token")}</label>
|
||||
<input id="globalGitlabAuthToken" className="input" type="password" autoComplete="off" value={globalGitlab.gitlabAuthToken ?? ""} disabled={globalGitlab.gitlabEnabled === false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabAuthToken: e.target.value || undefined })}/>
|
||||
<small className="settings-description">{t("settings.globalGeneral.gitLabAuthTokenHint", "Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; write actions need api; project/group tokens remain limited by resource membership. No default — unset.")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<CliBinaryPanel />
|
||||
<div className="form-group">
|
||||
<label htmlFor="dismissModalsOnOutsideClick" className="checkbox-label">
|
||||
<input id="dismissModalsOnOutsideClick" type="checkbox" checked={form.dismissModalsOnOutsideClick === true} onChange={(e) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: e.target.checked }))}/>{t("settings.globalGeneral.dismissModalsByClickingOutside", " Dismiss modals by clicking outside ")}</label>
|
||||
<small>{t("settings.globalGeneral.dismissModalsByClickingOutsideHint", " When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
|
||||
<input id="persistAgentToolOutput" type="checkbox" checked={form.persistAgentToolOutput === true} onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")}</label>
|
||||
<small>{t("settings.globalGeneral.whenDisabledToolRowsAreStillLoggedBut", " When disabled, tool rows are still logged but detailed tool payloads are omitted. Very large tool payloads may still be clipped even when this stays enabled. Default: disabled. ")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "dismissModalsOnOutsideClick",
|
||||
label: t("settings.globalGeneral.dismissModalsByClickingOutside", " Dismiss modals by clicking outside "),
|
||||
help: t("settings.globalGeneral.dismissModalsByClickingOutsideHint", " When enabled, clicking or tapping a modal backdrop closes the modal. Default: disabled, to prevent accidental dismissal. "),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.dismissModalsOnOutsideClick === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: v === true }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "persistAgentToolOutput",
|
||||
label: t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs "),
|
||||
help: t("settings.globalGeneral.whenDisabledToolRowsAreStillLoggedBut", " When disabled, tool rows are still logged but detailed tool payloads are omitted. Very large tool payloads may still be clipped even when this stays enabled. Default: disabled. "),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.persistAgentToolOutput === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, persistAgentToolOutput: v === true }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<h5 className="settings-section-heading">{t("settings.globalGeneral.saveAIThinkingLogs", "Save AI thinking logs")}</h5>
|
||||
<label htmlFor="persistAgentThinkingLogPermanent" className="checkbox-label">
|
||||
@@ -97,24 +62,41 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalSetting
|
||||
<small>{t("settings.globalGeneral.whenEnabledFusionChecksNpmForNewVersions", " When enabled, Fusion checks npm for new versions of")}{" "}
|
||||
<code>@runfusion/fusion</code>{t("settings.globalGeneral.andShowsUpdateNoticesInTheCLIAnd", " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="updateCheckFrequency">{t("settings.globalGeneral.frequency", "Frequency")}</label>
|
||||
<select id="updateCheckFrequency" value={form.updateCheckFrequency ?? "daily"} onChange={(e) => setForm((f) => ({
|
||||
{/*
|
||||
FNXC:SettingsGlobalGeneral 2026-07-15-17:35:
|
||||
Frequency is disabled rather than hidden while auto-check is off: it describes a cadence that is
|
||||
not running, and an operator turning checks back on needs to see which cadence will take effect.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "updateCheckFrequency",
|
||||
label: t("settings.globalGeneral.frequency", "Frequency"),
|
||||
help: t("settings.globalGeneral.controlsHowOftenTheDashboardReFetchesThe", " Controls how often the dashboard re-fetches the npm registry. Use the version + refresh control in the header to trigger an immediate check at any time. Default: daily. "),
|
||||
scope: "global",
|
||||
disabled: form.updateCheckEnabled === false,
|
||||
options: [
|
||||
{ value: "manual", label: t("settings.globalGeneral.manualOnlyNeverAutoCheck", "Manual only \u2014 never auto-check") },
|
||||
{ value: "on-startup", label: t("settings.globalGeneral.onStartupOncePerServerLaunch", "On startup \u2014 once per server launch") },
|
||||
{ value: "daily", label: t("settings.globalGeneral.dailyRecommended", "Daily (recommended)") },
|
||||
{ value: "weekly", label: t("settings.globalGeneral.weekly", "Weekly") },
|
||||
],
|
||||
}}
|
||||
value={form.updateCheckFrequency ?? "daily"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
updateCheckFrequency: e.target.value as "manual" | "on-startup" | "daily" | "weekly",
|
||||
}))} disabled={form.updateCheckEnabled === false}>
|
||||
<option value="manual">{t("settings.globalGeneral.manualOnlyNeverAutoCheck", "Manual only \u2014 never auto-check")}</option>
|
||||
<option value="on-startup">{t("settings.globalGeneral.onStartupOncePerServerLaunch", "On startup \u2014 once per server launch")}</option>
|
||||
<option value="daily">{t("settings.globalGeneral.dailyRecommended", "Daily (recommended)")}</option>
|
||||
<option value="weekly">{t("settings.globalGeneral.weekly", "Weekly")}</option>
|
||||
</select>
|
||||
<small>{t("settings.globalGeneral.controlsHowOftenTheDashboardReFetchesThe", " Controls how often the dashboard re-fetches the npm registry. Use the version + refresh control in the header to trigger an immediate check at any time. Default: daily. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoReloadOnVersionChange" className="checkbox-label">
|
||||
<input id="autoReloadOnVersionChange" type="checkbox" checked={form.autoReloadOnVersionChange !== false} onChange={(e) => setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))}/>{t("settings.globalGeneral.autoReloadDashboardOnVersionChange", " Auto-reload dashboard on version change ")}</label>
|
||||
<small>{t("settings.globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen", " When enabled (default), the dashboard automatically reloads when it detects a new build version \u2014 either from server rebuilds or service worker updates. Disable this to stay on the current version until you manually refresh. Default: enabled. ")}</small>
|
||||
</div>
|
||||
updateCheckFrequency: v as "manual" | "on-startup" | "daily" | "weekly",
|
||||
}))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "autoReloadOnVersionChange",
|
||||
label: t("settings.globalGeneral.autoReloadDashboardOnVersionChange", " Auto-reload dashboard on version change "),
|
||||
help: t("settings.globalGeneral.whenEnabledDefaultTheDashboardAutomaticallyReloadsWhen", " When enabled (default), the dashboard automatically reloads when it detects a new build version \u2014 either from server rebuilds or service worker updates. Disable this to stay on the current version until you manually refresh. Default: enabled. "),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.autoReloadOnVersionChange !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, autoReloadOnVersionChange: v === true }))}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
export default GlobalGeneralSection;
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { McpServersCard } from "./McpServersCard";
|
||||
|
||||
export interface GlobalMcpSectionProps {
|
||||
scopeBanner: ReactNode;
|
||||
form: Settings;
|
||||
setForm: Dispatch<SetStateAction<Settings>>;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function GlobalMcpSection({ scopeBanner, form, setForm, projectId, addToast }: GlobalMcpSectionProps) {
|
||||
export function GlobalMcpSection({ form, setForm, projectId, addToast }: GlobalMcpSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.nav.globalMcp", "MCP Servers")}</h4>
|
||||
<McpServersCard scope="global" form={form} setForm={setForm} projectId={projectId} addToast={addToast} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Search entries for the Global Models section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* Every OpenRouter advanced row is indexed even though the section renders them inside a collapsed <details>: search's whole purpose is finding a control an operator cannot see, and these are the ones most likely to be hunted by their stored field name (openrouterProviderPreferences.sort) rather than by browsing.
|
||||
* Absent by design: the Default/Fallback model pickers and the per-role model lanes (bespoke CustomModelDropdown widgets), the model pricing table this section mounts, and the opencode-go sync toggle, which stays hand-rolled for its <code> help markup and so has no descriptor key to anchor a result to.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const globalModelsSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "defaultThinkingLevel",
|
||||
labelKey: "settings.globalModels.thinkingEffort",
|
||||
labelFallback: "Thinking Effort",
|
||||
helpKey: "settings.globalModels.controlsHowMuchReasoningEffortTheAIModel",
|
||||
helpFallback:
|
||||
"Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. No default — unset (model's own default effort applies).",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The copy says "thinking"/"reasoning effort" but never names a level, and the level names live in option labels, which the index does not read. Operators search the level they want to set.
|
||||
*/
|
||||
keywords: ["high", "low", "medium", "xhigh", "extended thinking"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterModelSync",
|
||||
labelKey: "settings.globalModels.syncOpenRouterModelListAtStartup",
|
||||
labelFallback: " Sync OpenRouter model list at startup ",
|
||||
helpKey: "settings.globalModels.whenEnabledStartupFetchesTheLatestAvailableModels",
|
||||
helpFallback:
|
||||
" When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. Default: enabled. ",
|
||||
keywords: ["refresh", "boot"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterAppAttribution.referer",
|
||||
labelKey: "settings.globalModels.openRouterHTTPReferer",
|
||||
labelFallback: "OpenRouter HTTP-Referer",
|
||||
helpKey: "settings.globalModels.leaveEmptyToOmitThisHeaderDefaultHttps",
|
||||
helpFallback:
|
||||
"Leave empty to omit this header. No default — unset (Fusion falls back to https://runfusion.ai when unset).",
|
||||
keywords: ["attribution", "app ranking"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterAppAttribution.title",
|
||||
labelKey: "settings.globalModels.openRouterXTitle",
|
||||
labelFallback: "OpenRouter X-Title",
|
||||
helpKey: "settings.globalModels.leaveEmptyToOmitThisHeaderDefaultFusion",
|
||||
helpFallback:
|
||||
"Leave empty to omit this header. No default — unset (Fusion falls back to the title \"Fusion\" when unset).",
|
||||
keywords: ["attribution", "app ranking"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterModelFilters.supported_parameters",
|
||||
labelKey: "settings.globalModels.openRouterSupportedParametersFilter",
|
||||
labelFallback: "OpenRouter supported_parameters filter",
|
||||
helpKey: "settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync",
|
||||
helpFallback: "Comma-separated values sent to OpenRouter model sync. No default — unset (unfiltered).",
|
||||
keywords: ["tools", "structured outputs", "catalog filter"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterModelFilters.output_modalities",
|
||||
labelKey: "settings.globalModels.openRouterOutputModalitiesFilter",
|
||||
labelFallback: "OpenRouter output_modalities filter",
|
||||
helpKey: "settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities",
|
||||
helpFallback: "Comma-separated values sent to OpenRouter model sync. No default — unset (unfiltered).",
|
||||
keywords: ["text", "image", "catalog filter"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterProviderPreferences.order",
|
||||
labelKey: "settings.globalModels.openRouterRoutingOrder",
|
||||
labelFallback: "OpenRouter routing order",
|
||||
helpKey: "settings.globalModels.openRouterRoutingOrderHint",
|
||||
helpFallback: "No default — unset (OpenRouter's own default routing order applies).",
|
||||
keywords: ["provider preference", "priority"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterProviderPreferences.ignore",
|
||||
labelKey: "settings.globalModels.openRouterRoutingIgnore",
|
||||
labelFallback: "OpenRouter routing ignore",
|
||||
helpKey: "settings.globalModels.openRouterRoutingIgnoreHint",
|
||||
helpFallback: "No default — unset (no providers ignored).",
|
||||
keywords: ["exclude", "block", "deny provider"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterProviderPreferences.only",
|
||||
labelKey: "settings.globalModels.openRouterRoutingOnly",
|
||||
labelFallback: "OpenRouter routing only",
|
||||
helpKey: "settings.globalModels.openRouterRoutingOnlyHint",
|
||||
helpFallback: "No default — unset (no provider restriction).",
|
||||
keywords: ["allowlist", "restrict", "pin provider"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterProviderPreferences.allow_fallbacks",
|
||||
labelKey: "settings.globalModels.openRouterAllowFallbacks",
|
||||
labelFallback: "OpenRouter allow fallbacks",
|
||||
helpKey: "settings.globalModels.openRouterAllowFallbacksHint",
|
||||
helpFallback: "No default — unset (OpenRouter's own default fallback behavior applies).",
|
||||
keywords: ["backup provider", "failover"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterProviderPreferences.sort",
|
||||
labelKey: "settings.globalModels.openRouterRoutingSort",
|
||||
labelFallback: "OpenRouter routing sort",
|
||||
helpKey: "settings.globalModels.openRouterRoutingSortHint",
|
||||
helpFallback: "No default — unset (OpenRouter's own default sort applies).",
|
||||
keywords: ["price", "throughput", "latency", "cheapest", "fastest"],
|
||||
},
|
||||
{
|
||||
sectionId: "global-models",
|
||||
key: "openrouterProviderPreferences.require_parameters",
|
||||
labelKey: "settings.globalModels.requireParameters",
|
||||
labelFallback: " Require parameters ",
|
||||
helpKey: "settings.globalModels.requireParametersHint",
|
||||
helpFallback: "Default: disabled.",
|
||||
keywords: ["strict routing", "provider support"],
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { THINKING_LEVELS } from "@fusion/core";
|
||||
import type { Settings, ThinkingLevel } from "@fusion/core";
|
||||
@@ -6,6 +5,10 @@ import type { ModelInfo } from "../../../api";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { ModelPricingSection } from "./ModelPricingSection";
|
||||
import { CustomModelDropdown } from "../../CustomModelDropdown";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { SectionBaseProps, ModelLane } from "./context";
|
||||
import { LoadingSpinner } from "../../LoadingSpinner";
|
||||
function toCommaSeparatedInput(values?: string[]): string {
|
||||
@@ -15,7 +18,6 @@ function fromCommaSeparatedInput(value: string): string[] {
|
||||
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
||||
}
|
||||
export interface GlobalModelsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
availableModels: ModelInfo[];
|
||||
modelsLoading: boolean;
|
||||
/** Global model lanes (i.e. MODEL_LANES without the `default` lane). */
|
||||
@@ -30,21 +32,27 @@ export interface GlobalModelsSectionProps extends SectionBaseProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, addToast, projectId, }: GlobalModelsSectionProps) {
|
||||
export function GlobalModelsSection({ form, setForm, availableModels, modelsLoading, globalModelLanes, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, addToast, projectId, }: GlobalModelsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const selectedValue = form.defaultProvider && form.defaultModelId
|
||||
? `${form.defaultProvider}/${form.defaultModelId}`
|
||||
: "";
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
|
||||
{/* --- Default Model --- */}
|
||||
<h4 className="settings-section-heading">{t("settings.globalModels.defaultModel", "Default Model")}</h4>
|
||||
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.models.loadingModels", "Loading available models…")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">
|
||||
{t("settings.models.noModels", "No models available. Configure authentication first.")}
|
||||
</div>) : (<>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The Default and Fallback pickers stay bespoke (CustomModelDropdown owns the provider/model pair and its thinking companion), but each is still one control with one help string, so the help hangs off the same "?" as the Thinking Effort row rendered directly below them.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultModel">{t("settings.globalModels.defaultModel", "Default Model")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="defaultModel">{t("settings.globalModels.defaultModel", "Default Model")}</label>
|
||||
<SettingsHelpTip settingKey="defaultModel">{t("settings.globalModels.defaultAIModelUsedForTaskExecutionWhen", "Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. No default — unset.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<CustomModelDropdown id="defaultModel" label="Default Model" models={availableModels} value={selectedValue} onChange={(val) => {
|
||||
if (!val) {
|
||||
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined }));
|
||||
@@ -58,11 +66,13 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
}));
|
||||
}
|
||||
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
|
||||
<small>{t("settings.globalModels.defaultAIModelUsedForTaskExecutionWhen", "Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="fallbackModel">{t("settings.globalModels.fallbackModel", "Fallback Model")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="fallbackModel">{t("settings.globalModels.fallbackModel", "Fallback Model")}</label>
|
||||
<SettingsHelpTip settingKey="fallbackModel">{t("settings.globalModels.usedAutomaticallyIfThePrimaryDefaultModelHits", "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. No default \u2014 unset.")}</SettingsHelpTip>
|
||||
</div>
|
||||
{/* FNXC:Settings-ThinkingLevel 2026-07-10-12:00: Global fallback model selection owns its own thinking-level companion (`fallbackThinkingLevel`). Clearing the fallback picker must clear the companion value so null-as-delete reset parity matches the per-lane model pickers. */}
|
||||
<CustomModelDropdown id="fallbackModel" label="Fallback Model" models={availableModels} value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""} onChange={(val) => {
|
||||
if (!val) {
|
||||
@@ -80,27 +90,35 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
const selectedModel = availableModels.find((m) => m.provider === form.fallbackProvider && m.id === form.fallbackModelId);
|
||||
return selectedModel ? Boolean(selectedModel.reasoning) : true;
|
||||
})()} thinkingLevel={form.fallbackThinkingLevel || ""} onThinkingLevelChange={(level) => setForm((f) => ({ ...f, fallbackThinkingLevel: (level as ThinkingLevel) || undefined }))} defaultThinkingLevel={form.defaultThinkingLevel}/>
|
||||
<small>{t("settings.globalModels.usedAutomaticallyIfThePrimaryDefaultModelHits", "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
</>)}
|
||||
{(() => {
|
||||
const selectedModel = availableModels.find((m) => m.provider === form.defaultProvider && m.id === form.defaultModelId);
|
||||
if (selectedModel && !selectedModel.reasoning)
|
||||
return null;
|
||||
return (<div className="form-group">
|
||||
{/* FNXC:Settings-ThinkingLevel 2026-06-19-14:55: This global selector renders the canonical THINKING_LEVELS list so newly added `xhigh` stays available anywhere the default reasoning effort is configured. */}
|
||||
<label htmlFor="defaultThinkingLevel">{t("settings.globalModels.thinkingEffort", "Thinking Effort")}</label>
|
||||
<select id="defaultThinkingLevel" value={form.defaultThinkingLevel || ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
|
||||
}}>
|
||||
<option value="">{t("settings.globalModels.default", "Default")}</option>
|
||||
{THINKING_LEVELS.map((level) => (<option key={level} value={level}>
|
||||
{level.charAt(0).toUpperCase() + level.slice(1)}
|
||||
</option>))}
|
||||
</select>
|
||||
<small>{t("settings.globalModels.controlsHowMuchReasoningEffortTheAIModel", "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. No default \u2014 unset (model's own default effort applies).")}</small>
|
||||
</div>);
|
||||
return (
|
||||
/* FNXC:Settings-ThinkingLevel 2026-06-19-14:55: This global selector renders the canonical THINKING_LEVELS list so newly added `xhigh` stays available anywhere the default reasoning effort is configured. */
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "defaultThinkingLevel",
|
||||
label: t("settings.globalModels.thinkingEffort", "Thinking Effort"),
|
||||
help: t("settings.globalModels.controlsHowMuchReasoningEffortTheAIModel", "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. No default \u2014 unset (model's own default effort applies)."),
|
||||
scope: "global",
|
||||
/*
|
||||
FNXC:Settings-ThinkingLevel 2026-07-15-17:35:
|
||||
The empty option is the unset state, not a level: selecting it writes `undefined` so the model's own default effort applies. Level labels stay derived from THINKING_LEVELS rather than translated per level, exactly as before \u2014 the list is the canonical one, so a new level needs no copy change here.
|
||||
*/
|
||||
options: [
|
||||
{ value: "", label: t("settings.globalModels.default", "Default") },
|
||||
...THINKING_LEVELS.map((level) => ({
|
||||
value: level,
|
||||
label: level.charAt(0).toUpperCase() + level.slice(1),
|
||||
})),
|
||||
],
|
||||
}}
|
||||
value={form.defaultThinkingLevel || ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, defaultThinkingLevel: (v as ThinkingLevel) || undefined }))}
|
||||
/>);
|
||||
})()}
|
||||
|
||||
{availableModels.length > 0 && (<>
|
||||
@@ -112,7 +130,11 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
const value = provider && model ? `${provider}/${model}` : "";
|
||||
const thinkingValue = getLaneThinkingValue(lane);
|
||||
return (<div className="form-group" key={`global-${lane.laneId}`}>
|
||||
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-21:40: A global lane row is plain label + picker + one help string (unlike the project lanes, which add an inherited/override badge and a resolved fallback chain), so its helper text hangs off the shared "?" like every other row in this section. */}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
|
||||
<SettingsHelpTip settingKey={`global-${lane.laneId}-model`}>{lane.helperText}</SettingsHelpTip>
|
||||
</div>
|
||||
<CustomModelDropdown id={`global-${lane.laneId}-model`} label={lane.label} models={availableModels} value={value} onChange={(selected) => {
|
||||
if (!selected) {
|
||||
setForm((f) => ({
|
||||
@@ -130,7 +152,6 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
[lane.globalModelKey]: selected.slice(slashIdx + 1),
|
||||
}));
|
||||
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={Boolean(lane.globalThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
|
||||
<small>{lane.helperText}</small>
|
||||
</div>);
|
||||
})}
|
||||
</>)}
|
||||
@@ -139,44 +160,89 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
|
||||
{/* --- Startup Model Sync --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.startupModelSync", "Startup Model Sync")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
`!== false` is the enabled test, not `=== true`: this setting defaults to enabled in DEFAULT_GLOBAL_SETTINGS, so an unset key must read as on.
|
||||
*/}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "openrouterModelSync",
|
||||
label: t("settings.globalModels.syncOpenRouterModelListAtStartup", " Sync OpenRouter model list at startup "),
|
||||
help: t("settings.globalModels.whenEnabledStartupFetchesTheLatestAvailableModels", " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. Default: enabled. "),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.openrouterModelSync !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, openrouterModelSync: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Left on hand-rolled markup deliberately: its help text embeds a <code> element for the `opencode models opencode --refresh` command, and the primitives take help as a pre-translated string. Migrating it would mean either dropping the code formatting or splicing the command in as a bare literal, so the row keeps its markup until the descriptor can carry rich help.
|
||||
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The row still reads like its migrated neighbor above: `SettingsHelpTip` takes `ReactNode`, so the `<code>`-bearing copy moves behind the same "?" verbatim, without the flattening that kept it inline in the first place.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterModelSync" className="checkbox-label">
|
||||
<input id="openrouterModelSync" type="checkbox" checked={form.openrouterModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpenRouterModelListAtStartup", " Sync OpenRouter model list at startup ")}</label>
|
||||
<small>{t("settings.globalModels.whenEnabledStartupFetchesTheLatestAvailableModels", " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. Default: enabled. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="opencodeGoModelSync" className="checkbox-label">
|
||||
<input id="opencodeGoModelSync" type="checkbox" checked={form.opencodeGoModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpencodeGoModelListAtStartup", " Sync opencode-go model list at startup ")}</label>
|
||||
<small>{t("settings.globalModels.whenEnabledStartupRefreshesModelsThroughTheLocal", " When enabled, startup refreshes models through the local ")}<code>opencode models opencode --refresh</code>{t("settings.globalModels.flowAndPublishesThemUnderTheOpencodeGo", " flow and publishes them under the opencode-go provider in model pickers. Default: enabled. ")}</small>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="opencodeGoModelSync" className="checkbox-label">
|
||||
<input id="opencodeGoModelSync" type="checkbox" checked={form.opencodeGoModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpencodeGoModelListAtStartup", " Sync opencode-go model list at startup ")}</label>
|
||||
<SettingsHelpTip settingKey="opencodeGoModelSync">{t("settings.globalModels.whenEnabledStartupRefreshesModelsThroughTheLocal", " When enabled, startup refreshes models through the local ")}<code>opencode models opencode --refresh</code>{t("settings.globalModels.flowAndPublishesThemUnderTheOpencodeGo", " flow and publishes them under the opencode-go provider in model pickers. Default: enabled. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>{t("settings.globalModels.openRouterAdvanced", "OpenRouter advanced")}</summary>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterAppAttributionReferer">{t("settings.globalModels.openRouterHTTPReferer", "OpenRouter HTTP-Referer")}</label>
|
||||
<input id="openrouterAppAttributionReferer" className="input" placeholder={t("settings.globalModels.httpsRunfusionAi", "https://runfusion.ai")} value={form.openrouterAppAttribution?.referer ?? ""} onChange={(e) => setForm((f) => ({
|
||||
{/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
These rows edit leaves inside global blob settings (openrouterAppAttribution / openrouterModelFilters / openrouterProviderPreferences), so the descriptor key is the dotted path to the leaf. The parent blob is what lives in DEFAULT_GLOBAL_SETTINGS, which is what makes every one of them global scope.
|
||||
*/}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterAppAttribution.referer",
|
||||
label: t("settings.globalModels.openRouterHTTPReferer", "OpenRouter HTTP-Referer"),
|
||||
help: t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultHttps", "Leave empty to omit this header. No default — unset (Fusion falls back to https://runfusion.ai when unset)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.httpsRunfusionAi", "https://runfusion.ai"),
|
||||
}}
|
||||
value={form.openrouterAppAttribution?.referer ?? ""}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
openrouterAppAttribution: {
|
||||
...(f.openrouterAppAttribution || {}),
|
||||
referer: e.target.value,
|
||||
referer: v ?? "",
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultHttps", "Leave empty to omit this header. No default — unset (Fusion falls back to https://runfusion.ai when unset).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterAppAttributionTitle">{t("settings.globalModels.openRouterXTitle", "OpenRouter X-Title")}</label>
|
||||
<input id="openrouterAppAttributionTitle" className="input" placeholder={t("settings.globalModels.fusion", "Fusion")} value={form.openrouterAppAttribution?.title ?? ""} onChange={(e) => setForm((f) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterAppAttribution.title",
|
||||
label: t("settings.globalModels.openRouterXTitle", "OpenRouter X-Title"),
|
||||
help: t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultFusion", "Leave empty to omit this header. No default — unset (Fusion falls back to the title \"Fusion\" when unset)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.fusion", "Fusion"),
|
||||
}}
|
||||
value={form.openrouterAppAttribution?.title ?? ""}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
openrouterAppAttribution: {
|
||||
...(f.openrouterAppAttribution || {}),
|
||||
title: e.target.value,
|
||||
title: v ?? "",
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultFusion", "Leave empty to omit this header. No default — unset (Fusion falls back to the title \"Fusion\" when unset).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterModelFiltersSupportedParameters">{t("settings.globalModels.openRouterSupportedParametersFilter", "OpenRouter supported_parameters filter")}</label>
|
||||
<input id="openrouterModelFiltersSupportedParameters" className="input" placeholder={t("settings.globalModels.toolsStructuredOutputs", "tools, structured_outputs")} value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)} onChange={(e) => {
|
||||
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||
}))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
The comma-separated rows stay round-trip helpers over a string[]: an empty list writes `undefined` rather than `[]`, because an empty array would read as "filter to nothing" instead of "unfiltered".
|
||||
*/}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterModelFilters.supported_parameters",
|
||||
label: t("settings.globalModels.openRouterSupportedParametersFilter", "OpenRouter supported_parameters filter"),
|
||||
help: t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync. No default \u2014 unset (unfiltered)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.toolsStructuredOutputs", "tools, structured_outputs"),
|
||||
}}
|
||||
value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)}
|
||||
onChange={(v) => {
|
||||
const parsed = fromCommaSeparatedInput(v ?? "");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
openrouterModelFilters: {
|
||||
@@ -184,13 +250,19 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
supported_parameters: parsed.length > 0 ? parsed : undefined,
|
||||
},
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync. No default \u2014 unset (unfiltered).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterModelFiltersOutputModalities">{t("settings.globalModels.openRouterOutputModalitiesFilter", "OpenRouter output_modalities filter")}</label>
|
||||
<input id="openrouterModelFiltersOutputModalities" className="input" placeholder={t("settings.globalModels.text", "text")} value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)} onChange={(e) => {
|
||||
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterModelFilters.output_modalities",
|
||||
label: t("settings.globalModels.openRouterOutputModalitiesFilter", "OpenRouter output_modalities filter"),
|
||||
help: t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities", "Comma-separated values sent to OpenRouter model sync. No default \u2014 unset (unfiltered)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.text", "text"),
|
||||
}}
|
||||
value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)}
|
||||
onChange={(v) => {
|
||||
const parsed = fromCommaSeparatedInput(v ?? "");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
openrouterModelFilters: {
|
||||
@@ -198,13 +270,19 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
output_modalities: parsed.length > 0 ? parsed : undefined,
|
||||
},
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities", "Comma-separated values sent to OpenRouter model sync. No default \u2014 unset (unfiltered).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterProviderPreferencesOrder">{t("settings.globalModels.openRouterRoutingOrder", "OpenRouter routing order")}</label>
|
||||
<input id="openrouterProviderPreferencesOrder" className="input" placeholder={t("settings.globalModels.openaiAnthropic", "openai, anthropic")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)} onChange={(e) => {
|
||||
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterProviderPreferences.order",
|
||||
label: t("settings.globalModels.openRouterRoutingOrder", "OpenRouter routing order"),
|
||||
help: t("settings.globalModels.openRouterRoutingOrderHint", "No default \u2014 unset (OpenRouter's own default routing order applies)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.openaiAnthropic", "openai, anthropic"),
|
||||
}}
|
||||
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)}
|
||||
onChange={(v) => {
|
||||
const parsed = fromCommaSeparatedInput(v ?? "");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
openrouterProviderPreferences: {
|
||||
@@ -212,13 +290,19 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
order: parsed.length > 0 ? parsed : undefined,
|
||||
},
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.globalModels.openRouterRoutingOrderHint", "No default \u2014 unset (OpenRouter's own default routing order applies).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterProviderPreferencesIgnore">{t("settings.globalModels.openRouterRoutingIgnore", "OpenRouter routing ignore")}</label>
|
||||
<input id="openrouterProviderPreferencesIgnore" className="input" placeholder={t("settings.globalModels.providerName", "provider-name")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)} onChange={(e) => {
|
||||
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterProviderPreferences.ignore",
|
||||
label: t("settings.globalModels.openRouterRoutingIgnore", "OpenRouter routing ignore"),
|
||||
help: t("settings.globalModels.openRouterRoutingIgnoreHint", "No default \u2014 unset (no providers ignored)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.providerName", "provider-name"),
|
||||
}}
|
||||
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)}
|
||||
onChange={(v) => {
|
||||
const parsed = fromCommaSeparatedInput(v ?? "");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
openrouterProviderPreferences: {
|
||||
@@ -226,13 +310,19 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
ignore: parsed.length > 0 ? parsed : undefined,
|
||||
},
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.globalModels.openRouterRoutingIgnoreHint", "No default \u2014 unset (no providers ignored).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterProviderPreferencesOnly">{t("settings.globalModels.openRouterRoutingOnly", "OpenRouter routing only")}</label>
|
||||
<input id="openrouterProviderPreferencesOnly" className="input" placeholder={t("settings.globalModels.providerName", "provider-name")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)} onChange={(e) => {
|
||||
const parsed = fromCommaSeparatedInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "openrouterProviderPreferences.only",
|
||||
label: t("settings.globalModels.openRouterRoutingOnly", "OpenRouter routing only"),
|
||||
help: t("settings.globalModels.openRouterRoutingOnlyHint", "No default \u2014 unset (no provider restriction)."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.globalModels.providerName", "provider-name"),
|
||||
}}
|
||||
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)}
|
||||
onChange={(v) => {
|
||||
const parsed = fromCommaSeparatedInput(v ?? "");
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
openrouterProviderPreferences: {
|
||||
@@ -240,57 +330,71 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
|
||||
only: parsed.length > 0 ? parsed : undefined,
|
||||
},
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.globalModels.openRouterRoutingOnlyHint", "No default \u2014 unset (no provider restriction).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">{t("settings.globalModels.openRouterAllowFallbacks", "OpenRouter allow fallbacks")}</label>
|
||||
<select id="openrouterProviderPreferencesAllowFallbacks" className="select" value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"} onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
"default" is a sentinel option, not a stored value: both of these routing preferences are tri-state (unset / explicit A / explicit B), and selecting it writes `undefined` so OpenRouter's own default applies rather than Fusion pinning one.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "openrouterProviderPreferences.allow_fallbacks",
|
||||
label: t("settings.globalModels.openRouterAllowFallbacks", "OpenRouter allow fallbacks"),
|
||||
help: t("settings.globalModels.openRouterAllowFallbacksHint", "No default \u2014 unset (OpenRouter's own default fallback behavior applies)."),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "default", label: t("settings.globalModels.default2", "default") },
|
||||
{ value: "allow", label: t("settings.globalModels.allow", "allow") },
|
||||
{ value: "deny", label: t("settings.globalModels.deny", "deny") },
|
||||
],
|
||||
}}
|
||||
value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
openrouterProviderPreferences: {
|
||||
...(f.openrouterProviderPreferences || {}),
|
||||
allow_fallbacks: value === "default" ? undefined : value === "allow",
|
||||
allow_fallbacks: v === "default" ? undefined : v === "allow",
|
||||
},
|
||||
}));
|
||||
}}>
|
||||
<option value="default">{t("settings.globalModels.default2", "default")}</option>
|
||||
<option value="allow">{t("settings.globalModels.allow", "allow")}</option>
|
||||
<option value="deny">{t("settings.globalModels.deny", "deny")}</option>
|
||||
</select>
|
||||
<small>{t("settings.globalModels.openRouterAllowFallbacksHint", "No default \u2014 unset (OpenRouter's own default fallback behavior applies).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterProviderPreferencesSort">{t("settings.globalModels.openRouterRoutingSort", "OpenRouter routing sort")}</label>
|
||||
<select id="openrouterProviderPreferencesSort" className="select" value={form.openrouterProviderPreferences?.sort ?? "default"} onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "openrouterProviderPreferences.sort",
|
||||
label: t("settings.globalModels.openRouterRoutingSort", "OpenRouter routing sort"),
|
||||
help: t("settings.globalModels.openRouterRoutingSortHint", "No default \u2014 unset (OpenRouter's own default sort applies)."),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "default", label: t("settings.globalModels.default2", "default") },
|
||||
{ value: "price", label: t("settings.globalModels.price", "price") },
|
||||
{ value: "throughput", label: t("settings.globalModels.throughput", "throughput") },
|
||||
{ value: "latency", label: t("settings.globalModels.latency", "latency") },
|
||||
],
|
||||
}}
|
||||
value={form.openrouterProviderPreferences?.sort ?? "default"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
openrouterProviderPreferences: {
|
||||
...(f.openrouterProviderPreferences || {}),
|
||||
sort: value === "default" ? undefined : value as "price" | "throughput" | "latency",
|
||||
sort: v === "default" ? undefined : v as "price" | "throughput" | "latency",
|
||||
},
|
||||
}));
|
||||
}}>
|
||||
<option value="default">{t("settings.globalModels.default2", "default")}</option>
|
||||
<option value="price">{t("settings.globalModels.price", "price")}</option>
|
||||
<option value="throughput">{t("settings.globalModels.throughput", "throughput")}</option>
|
||||
<option value="latency">{t("settings.globalModels.latency", "latency")}</option>
|
||||
</select>
|
||||
<small>{t("settings.globalModels.openRouterRoutingSortHint", "No default \u2014 unset (OpenRouter's own default sort applies).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openrouterProviderPreferencesRequireParameters" className="checkbox-label">
|
||||
<input id="openrouterProviderPreferencesRequireParameters" type="checkbox" checked={form.openrouterProviderPreferences?.require_parameters === true} onChange={(e) => setForm((f) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "openrouterProviderPreferences.require_parameters",
|
||||
label: t("settings.globalModels.requireParameters", " Require parameters "),
|
||||
help: t("settings.globalModels.requireParametersHint", "Default: disabled."),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.openrouterProviderPreferences?.require_parameters === true}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
openrouterProviderPreferences: {
|
||||
...(f.openrouterProviderPreferences || {}),
|
||||
require_parameters: e.target.checked,
|
||||
require_parameters: v === true,
|
||||
},
|
||||
}))}/>{t("settings.globalModels.requireParameters", " Require parameters ")}</label>
|
||||
<small>{t("settings.globalModels.requireParametersHint", "Default: disabled.")}</small>
|
||||
</div>
|
||||
}))}
|
||||
/>
|
||||
</details>
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { ShortcutCaptureInput } from "./ShortcutCaptureInput";
|
||||
@@ -12,15 +11,16 @@ import {
|
||||
type DashboardShortcutAction,
|
||||
} from "../../../utils/keyboardShortcuts";
|
||||
|
||||
export interface KeyboardShortcutsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
}
|
||||
export type KeyboardShortcutsSectionProps = SectionBaseProps;
|
||||
|
||||
/*
|
||||
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
||||
FN-7553 promotes keyboard shortcuts from two bare inputs buried in Global General to their own dedicated settings section, grouped by category (Communication/Workspace/Navigation/Tasks from SHORTCUT_CATEGORIES) with a press-to-record capture control per row. `dashboardKeyboardShortcuts` ownership moved here from `global-general` (save-split.ts GLOBAL_SECTION_KEYS + section-keys.ts) so exactly one section owns the key for save/reset.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
This section stays off the shared settings row primitives, unlike its neighbors. It renders no plain setting: every row is a ShortcutCaptureInput bound to one action inside the single `dashboardKeyboardShortcuts` map, and its per-row `small` carries live capture validation (`normalizeKeyboardShortcut` errors) rather than static help copy. A descriptor row keys on a settings field name and there is no field per shortcut — so there is nothing here for a toggle/text/select row to own, and no `.search.ts` sibling. Shortcut discovery is served by the nav entry's `searchableText` in SettingsModal instead.
|
||||
*/
|
||||
export function KeyboardShortcutsSection({ scopeBanner, form, setForm }: KeyboardShortcutsSectionProps) {
|
||||
export function KeyboardShortcutsSection({ form, setForm }: KeyboardShortcutsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const shortcutValues = resolveDashboardKeyboardShortcuts(form.dashboardKeyboardShortcuts);
|
||||
const shortcutValidationMessage = describeShortcutValidation(shortcutValues);
|
||||
@@ -35,7 +35,6 @@ export function KeyboardShortcutsSection({ scopeBanner, form, setForm }: Keyboar
|
||||
|
||||
return (
|
||||
<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.keyboardShortcuts.title", "Keyboard Shortcuts")}</h4>
|
||||
<p className="settings-description">{t("settings.keyboardShortcuts.hint", "Configure global dashboard shortcuts. Click Record and press a combination, or type one manually. Shortcuts are ignored while typing in inputs, editors, chat composers, and terminal fields. Leave blank to disable an action.")}</p>
|
||||
<div className="form-group settings-keyboard-shortcuts" data-testid="keyboard-shortcuts-settings">
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Search entries for the Memory section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per settings control the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's retrieval tester, memory-file picker, and file editor are deliberately absent — they edit no settings key, so they are not settings an operator can search for.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const memorySearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "memoryEnabled",
|
||||
labelKey: "settings.memory.enableMemoryTools",
|
||||
labelFallback: " Enable memory tools ",
|
||||
helpKey: "settings.memory.agentsGetMemorySearchMemoryGetAndMemory",
|
||||
helpFallback:
|
||||
"Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback. Default: enabled.",
|
||||
keywords: ["recall", "knowledge"],
|
||||
},
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "memoryAutoSummarizeEnabled",
|
||||
labelKey: "settings.memory.autoSummarizeMemory",
|
||||
labelFallback: " Auto-Summarize Memory ",
|
||||
helpKey: "settings.memory.automaticallyCompactMemoryWhenItExceedsTheThreshold",
|
||||
helpFallback:
|
||||
"Automatically compact memory when it exceeds the threshold on a schedule. Default: disabled.",
|
||||
keywords: ["condense", "prune", "shrink"],
|
||||
},
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "memoryAutoSummarizeThresholdChars",
|
||||
labelKey: "settings.memory.compactionThresholdChars",
|
||||
labelFallback: "Compaction Threshold (chars)",
|
||||
helpKey: "settings.memory.memoryWillBeCompactedWhenItExceedsThis",
|
||||
helpFallback:
|
||||
"Memory will be compacted when it exceeds this character count. Default: 50000.",
|
||||
keywords: ["size limit", "trigger"],
|
||||
},
|
||||
{
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Auto-summarize and insight extraction share the label "Schedule (cron)" and its i18n key, so entries are distinguished by `key` alone. This is exactly why the index is keyed by settings field name rather than i18n key — two controls here would otherwise collide.
|
||||
*/
|
||||
sectionId: "memory",
|
||||
key: "memoryAutoSummarizeSchedule",
|
||||
labelKey: "settings.memory.scheduleCron",
|
||||
labelFallback: "Schedule (cron)",
|
||||
helpKey: "settings.memory.cronExpressionForAutoSummarizeScheduleDefaultDaily",
|
||||
helpFallback:
|
||||
"Cron expression for auto-summarize schedule. Default: 0 3 * * * (daily at 3 AM).",
|
||||
keywords: ["auto-summarize", "compaction schedule", "timing"],
|
||||
},
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "insightExtractionEnabled",
|
||||
labelKey: "settings.memory.enableInsightExtraction",
|
||||
labelFallback: " Enable Insight Extraction ",
|
||||
helpKey: "settings.memory.periodicallyExtractDurableInsightsFromCompletedTasks",
|
||||
helpFallback:
|
||||
"Periodically extract durable insights/learnings from completed tasks into memory",
|
||||
keywords: ["lessons", "retrospective"],
|
||||
},
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "insightExtractionSchedule",
|
||||
labelKey: "settings.memory.scheduleCron",
|
||||
labelFallback: "Schedule (cron)",
|
||||
helpKey: "settings.memory.cronExpressionForInsightExtractionScheduleDefaultDaily",
|
||||
helpFallback:
|
||||
"Cron expression for insight extraction schedule (default: daily at 2 AM)",
|
||||
keywords: ["insight", "timing"],
|
||||
},
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "memoryDreamsEnabled",
|
||||
labelKey: "settings.memory.processDreamsFromDailyMemory",
|
||||
labelFallback: " Process dreams from daily memory ",
|
||||
helpKey: "settings.memory.turnsDailyNotesIntoDREAMSMdAndPromotes",
|
||||
helpFallback:
|
||||
"Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. Default: disabled.",
|
||||
keywords: ["synthesis", "consolidation"],
|
||||
},
|
||||
{
|
||||
sectionId: "memory",
|
||||
key: "memoryDreamsSchedule",
|
||||
labelKey: "settings.memory.dreamSchedule",
|
||||
labelFallback: "Dream Schedule",
|
||||
helpKey: "settings.memory.cronExpressionForDreamProcessing",
|
||||
helpFallback:
|
||||
"Cron expression for dream processing. Default: 0 4 * * * (daily at 4 AM).",
|
||||
keywords: ["timing"],
|
||||
},
|
||||
];
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MemoryBackendCapabilities, MemoryBackendStatus, MemoryFileInfo, MemoryRetrievalTestResult, } from "../../../api";
|
||||
import { FileEditor } from "../../FileEditor";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { LoadingSpinner } from "../../LoadingSpinner";
|
||||
const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72;
|
||||
@@ -46,10 +49,9 @@ export interface MemorySectionMemoryProps {
|
||||
onSaveMemory: () => void;
|
||||
}
|
||||
export interface MemorySectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
memory: MemorySectionMemoryProps;
|
||||
}
|
||||
export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySectionProps) {
|
||||
export function MemorySection({ form, setForm, memory }: MemorySectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { memoryCapabilities: capabilities, memoryBackendStatus: backendStatus, memoryBackendLoading: backendLoading, memoryBackendError: backendError, memoryFiles, selectedMemoryPath, setSelectedMemoryPath, memoryContent, setMemoryContent, memoryLoading, memoryDirty, setMemoryDirty, memoryTestQuery, setMemoryTestQuery, memoryTestLoading, memoryTestResult, qmdInstallLoading, dreamRunning, memoryCompactLoading, onInstallQmd, onTestMemoryRetrieval, onDreamNow, onCompactMemory, onSaveMemory, } = memory;
|
||||
// Determine if editing is allowed
|
||||
@@ -64,17 +66,21 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect
|
||||
dreams: "Dreams",
|
||||
};
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.memory.memory", "Memory")}</h4>
|
||||
<div className="form-group">
|
||||
<small className="settings-muted">{t("settings.memory.memoryLivesIn", " Memory lives in ")}<code>.fusion/memory/</code>{t("settings.memory.agentsSearchWithQmdFirstFallBackTo", ". Agents search with qmd first, fall back to local files when qmd is missing, and open exact line windows only when needed. ")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryEnabled" className="checkbox-label">
|
||||
<input id="memoryEnabled" type="checkbox" checked={form.memoryEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))}/>{t("settings.memory.enableMemoryTools", " Enable memory tools ")}</label>
|
||||
<small>{t("settings.memory.agentsGetMemorySearchMemoryGetAndMemory", "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback. Default: enabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "memoryEnabled",
|
||||
label: t("settings.memory.enableMemoryTools", " Enable memory tools "),
|
||||
help: t("settings.memory.agentsGetMemorySearchMemoryGetAndMemory", "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.memoryEnabled !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryEnabled: v === true }))}
|
||||
/>
|
||||
|
||||
{backendLoading ? (<div className="form-group">
|
||||
<small className="settings-muted">{t("settings.memory.checkingMemoryWriteAccess", "Checking memory write access...")}</small>
|
||||
@@ -90,69 +96,127 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect
|
||||
</button>
|
||||
</div>)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeEnabled" className="checkbox-label">
|
||||
<input id="memoryAutoSummarizeEnabled" type="checkbox" checked={form.memoryAutoSummarizeEnabled || false} onChange={(e) => setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: e.target.checked }))}/>{t("settings.memory.autoSummarizeMemory", " Auto-Summarize Memory ")}</label>
|
||||
<small>{t("settings.memory.automaticallyCompactMemoryWhenItExceedsTheThreshold", "Automatically compact memory when it exceeds the threshold on a schedule. Default: disabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "memoryAutoSummarizeEnabled",
|
||||
label: t("settings.memory.autoSummarizeMemory", " Auto-Summarize Memory "),
|
||||
help: t("settings.memory.automaticallyCompactMemoryWhenItExceedsTheThreshold", "Automatically compact memory when it exceeds the threshold on a schedule. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.memoryAutoSummarizeEnabled || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryAutoSummarizeEnabled: v === true }))}
|
||||
/>
|
||||
|
||||
{(form.memoryAutoSummarizeEnabled || false) && (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeThresholdChars">{t("settings.memory.compactionThresholdChars", "Compaction Threshold (chars)")}</label>
|
||||
<input id="memoryAutoSummarizeThresholdChars" type="number" className="input" value={form.memoryAutoSummarizeThresholdChars ?? 50000} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
memoryAutoSummarizeThresholdChars: parseInt(e.target.value, 10) || 50000,
|
||||
}))} min={1000}/>
|
||||
<small>{t("settings.memory.memoryWillBeCompactedWhenItExceedsThis", "Memory will be compacted when it exceeds this character count. Default: 50000.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeSchedule">{t("settings.memory.scheduleCron", "Schedule (cron)")}</label>
|
||||
<input id="memoryAutoSummarizeSchedule" type="text" className="input" value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"} onChange={(e) => setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: e.target.value }))} placeholder={t("settings.memory.03", "0 3 * * *")}/>
|
||||
<small>{t("settings.memory.cronExpressionForAutoSummarizeScheduleDefaultDaily", "Cron expression for auto-summarize schedule. Default: 0 3 * * * (daily at 3 AM).")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:MemoryCompaction 2026-07-15-17:35:
|
||||
An empty or unparseable threshold falls back to the 50000 schema default rather than persisting undefined: the auto-summarize scheduler reads this value directly, so a blank field must still compact at the documented default instead of disabling compaction silently.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "memoryAutoSummarizeThresholdChars",
|
||||
label: t("settings.memory.compactionThresholdChars", "Compaction Threshold (chars)"),
|
||||
help: t("settings.memory.memoryWillBeCompactedWhenItExceedsThis", "Memory will be compacted when it exceeds this character count. Default: 50000."),
|
||||
scope: "project",
|
||||
min: 1000,
|
||||
}}
|
||||
value={form.memoryAutoSummarizeThresholdChars ?? 50000}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryAutoSummarizeThresholdChars: v || 50000 }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "memoryAutoSummarizeSchedule",
|
||||
label: t("settings.memory.scheduleCron", "Schedule (cron)"),
|
||||
help: t("settings.memory.cronExpressionForAutoSummarizeScheduleDefaultDaily", "Cron expression for auto-summarize schedule. Default: 0 3 * * * (daily at 3 AM)."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.memory.03", "0 3 * * *"),
|
||||
}}
|
||||
value={form.memoryAutoSummarizeSchedule ?? "0 3 * * *"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryAutoSummarizeSchedule: v ?? "" }))}
|
||||
/>
|
||||
</>)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="insightExtractionEnabled" className="checkbox-label">
|
||||
<input id="insightExtractionEnabled" type="checkbox" checked={form.insightExtractionEnabled || false} onChange={(e) => setForm((f) => ({ ...f, insightExtractionEnabled: e.target.checked }))}/>{t("settings.memory.enableInsightExtraction", " Enable Insight Extraction ")}</label>
|
||||
<small>{t("settings.memory.periodicallyExtractDurableInsightsFromCompletedTasks", "Periodically extract durable insights/learnings from completed tasks into memory")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "insightExtractionEnabled",
|
||||
label: t("settings.memory.enableInsightExtraction", " Enable Insight Extraction "),
|
||||
help: t("settings.memory.periodicallyExtractDurableInsightsFromCompletedTasks", "Periodically extract durable insights/learnings from completed tasks into memory"),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.insightExtractionEnabled || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, insightExtractionEnabled: v === true }))}
|
||||
/>
|
||||
|
||||
{(form.insightExtractionEnabled || false) && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="insightExtractionSchedule">{t("settings.memory.scheduleCron", "Schedule (cron)")}</label>
|
||||
<input id="insightExtractionSchedule" type="text" className="input" value={form.insightExtractionSchedule ?? "0 2 * * *"} onChange={(e) => setForm((f) => ({ ...f, insightExtractionSchedule: e.target.value }))} placeholder={t("settings.memory.02", "0 2 * * *")}/>
|
||||
<small>{t("settings.memory.cronExpressionForInsightExtractionScheduleDefaultDaily", "Cron expression for insight extraction schedule (default: daily at 2 AM)")}</small>
|
||||
</div>)}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "insightExtractionSchedule",
|
||||
label: t("settings.memory.scheduleCron", "Schedule (cron)"),
|
||||
help: t("settings.memory.cronExpressionForInsightExtractionScheduleDefaultDaily", "Cron expression for insight extraction schedule (default: daily at 2 AM)"),
|
||||
scope: "project",
|
||||
placeholder: t("settings.memory.02", "0 2 * * *"),
|
||||
}}
|
||||
value={form.insightExtractionSchedule ?? "0 2 * * *"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, insightExtractionSchedule: v ?? "" }))}
|
||||
/>)}
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }}/>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsEnabled" className="checkbox-label">
|
||||
<input id="memoryDreamsEnabled" type="checkbox" checked={form.memoryDreamsEnabled === true} onChange={(e) => setForm((f) => ({ ...f, memoryDreamsEnabled: e.target.checked }))} disabled={!isMemoryEnabled}/>{t("settings.memory.processDreamsFromDailyMemory", " Process dreams from daily memory ")}</label>
|
||||
<small>{t("settings.memory.turnsDailyNotesIntoDREAMSMdAndPromotes", "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. Default: disabled.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:MemoryDreams 2026-07-15-17:35:
|
||||
Dream processing reads the daily memory layer, so the toggle is disabled whenever memory tools are off — there is nothing to synthesize from. The schedule row below stays gated on both flags for the same reason.
|
||||
*/}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "memoryDreamsEnabled",
|
||||
label: t("settings.memory.processDreamsFromDailyMemory", " Process dreams from daily memory "),
|
||||
help: t("settings.memory.turnsDailyNotesIntoDREAMSMdAndPromotes", "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md. Default: disabled."),
|
||||
scope: "project",
|
||||
disabled: !isMemoryEnabled,
|
||||
}}
|
||||
value={form.memoryDreamsEnabled === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryDreamsEnabled: v === true }))}
|
||||
/>
|
||||
|
||||
{isMemoryEnabled && form.memoryDreamsEnabled === true && (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">{t("settings.memory.dreamSchedule", "Dream Schedule")}</label>
|
||||
<input id="memoryDreamsSchedule" type="text" value={form.memoryDreamsSchedule ?? "0 4 * * *"} onChange={(e) => setForm((f) => ({ ...f, memoryDreamsSchedule: e.target.value }))}/>
|
||||
<small>{t("settings.memory.cronExpressionForDreamProcessing", "Cron expression for dream processing. Default: 0 4 * * * (daily at 4 AM).")}</small>
|
||||
</div>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "memoryDreamsSchedule",
|
||||
label: t("settings.memory.dreamSchedule", "Dream Schedule"),
|
||||
help: t("settings.memory.cronExpressionForDreamProcessing", "Cron expression for dream processing. Default: 0 4 * * * (daily at 4 AM)."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.memoryDreamsSchedule ?? "0 4 * * *"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, memoryDreamsSchedule: v ?? "" }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<button type="button" className="btn btn-sm" onClick={onDreamNow} disabled={dreamRunning || form.memoryDreamsEnabled !== true}>
|
||||
{dreamRunning ? (<>
|
||||
<Loader2 size={14} className="animate-spin"/>{t("settings.memory.dreaming", " Dreaming\u2026 ")}</>) : (t("settings.memory.dreamNow", "Dream Now"))}
|
||||
</button>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Stays inline (same for "Compact Selected File" below): the affordance is a BUTTON, not a labelled control, so there is no label line for a tip to sit on. Hiding a one-shot action's description behind a "?" beside a button would hide what the button does.
|
||||
*/}
|
||||
<small>{t("settings.memory.manuallyTriggerDreamProcessingNow", "Manually trigger dream processing now.")}</small>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The retrieval tester, memory-file picker, and editor below stay on plain `form-group` markup on purpose: none of them edits a settings key. They are a transient query box, a file selector gated on unsaved edits, and a document editor, so rendering them as settings rows would file them in the settings search index as configuration an operator can set — which they are not.
|
||||
*/}
|
||||
<div className="memory-retrieval-test">
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryRetrievalQuery">{t("settings.memory.testRetrieval", "Test Retrieval")}</label>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The tester is not a settings key (see the note above), but its help still hangs off the shared "?" so this section does not mix a help icon and a paragraph in adjacent rows. `settingKey` reuses the input's id — the tip only needs a stable handle for its bubble id, not a real settings key.
|
||||
*/}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="memoryRetrievalQuery">{t("settings.memory.testRetrieval", "Test Retrieval")}</label>
|
||||
<SettingsHelpTip settingKey="memoryRetrievalQuery">{t("settings.memory.runsTheSameQmdBackedMemorySearchPath", "Runs the same qmd-backed memory_search path agents use.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="memoryRetrievalQuery" type="text" value={memoryTestQuery} onChange={(e) => setMemoryTestQuery(e.target.value)} placeholder={t("settings.memory.searchMemoryWithQmd", "Search memory with qmd")}/>
|
||||
<small>{t("settings.memory.runsTheSameQmdBackedMemorySearchPath", "Runs the same qmd-backed memory_search path agents use.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={onTestMemoryRetrieval} disabled={memoryTestLoading}>
|
||||
@@ -189,6 +253,10 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect
|
||||
{formatMemoryFileOptionLabel(file)}
|
||||
</option>))}
|
||||
</select>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Stays inline: in the dirty branch this copy is the reason the select is DISABLED, not help. An operator who finds the picker greyed out must be told why without hunting for a "?" — so the whole `<small>` stays visible rather than splitting one string across two affordances by state.
|
||||
*/}
|
||||
<small>
|
||||
{memoryDirty
|
||||
? "Save or discard the current edits before switching files."
|
||||
@@ -204,6 +272,10 @@ export function MemorySection({ scopeBanner, form, setForm, memory }: MemorySect
|
||||
</div>)}
|
||||
<div className="form-group memory-editor-form-group">
|
||||
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Stays inline: this describes what the SELECTED FILE holds and changes with the picker above, so it reads as content orientation for the editor pane, not as help for a control. It also labels a document editor rather than a settings control, which is why it never had an id to hang a tip's key off.
|
||||
*/}
|
||||
<small>
|
||||
{selectedMemoryFile?.layer === "long-term" && "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams."}
|
||||
{selectedMemoryFile?.layer === "daily" && "Raw daily observations, open loops, and running context for dream processing."}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Search entries for the Merge section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* This list is short because most of Merge is deliberately still bespoke — "More details" disclosure rows, custom branch/remote dropdowns, and rows whose help interleaves `<code>` fragments. See the FNXC block in MergeSection.tsx for why each stayed. Those settings remain reachable through the section's own `searchableText` in SETTINGS_SECTIONS; they simply have no per-control anchor yet.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-20:30:
|
||||
* The GitHub/GitLab auth entries moved to SourceControlSection.search.ts with their controls. Merge no longer renders any forge auth row, so password inputs are no longer among this section's reasons for staying bespoke.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const mergeSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "merge",
|
||||
key: "maxAutoMergeRetries",
|
||||
labelKey: "settings.merge.autoMergeConflictRetries",
|
||||
labelFallback: "Auto-merge conflict retries",
|
||||
helpKey: "settings.merge.positiveIntegerRetryCapForAutoMergeConflict",
|
||||
helpFallback:
|
||||
"Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
|
||||
keywords: ["attempts", "give up", "parked", "merge failure"],
|
||||
},
|
||||
{
|
||||
sectionId: "merge",
|
||||
key: "merger.maxReviewPasses",
|
||||
labelKey: "settings.merge.maxAIReviewPasses",
|
||||
labelFallback: "Max AI review passes",
|
||||
helpKey: "settings.merge.aICorrectiveRoundsBeforeLandingTheBestResult",
|
||||
helpFallback:
|
||||
"AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model.",
|
||||
keywords: ["clean room", "audit", "retries", "attempts"],
|
||||
},
|
||||
{
|
||||
sectionId: "merge",
|
||||
key: "mergeStrategyOverlapBehavior",
|
||||
labelKey: "settings.merge.smartPreferMainOverlapGuard",
|
||||
labelFallback: "Smart Prefer Main Overlap Guard",
|
||||
helpKey: "settings.merge.whenUsingSmartPreferMainAutomaticallyPreferThe",
|
||||
helpFallback:
|
||||
" When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. ",
|
||||
keywords: ["ours", "theirs", "lost work", "clobber"],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import { fetchGitRemoteBranches } from "../../../api";
|
||||
import { MovedSettingsStub } from "./MovedSettingsStub";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
/*
|
||||
FNXC:MergePush 2026-07-11-23:00:
|
||||
@@ -50,8 +52,29 @@ async function readLegacyAutoMergeStampResponse(response: Response): Promise<Leg
|
||||
}
|
||||
return response.json() as Promise<LegacyAutoMergeStampListResponse>;
|
||||
}
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The plain label+control+help rows here render through the shared settings primitives instead of hand-rolled `form-group` markup, so their labels, help copy, and padding come from the one settings type scale. `.form-group` itself stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath the rest of the dashboard.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Every migrated key here is project-scoped (`DEFAULT_PROJECT_SETTINGS`): merge policy, retry caps, and auth mode describe one repository's landing strategy. The badges restate that per row because settings search can land an operator on a single control with no section chrome in view.
|
||||
|
||||
FNXC:SettingsHelp 2026-07-15-23:20:
|
||||
The per-row "More details" `<details className="settings-option-details">` disclosures this section invented are gone; their help now hangs off the shared "?" (`.settings-field-label-row` + `SettingsHelpTip`), the same affordance every other section uses. This supersedes the earlier reasoning that Merge help was too long to render inline and therefore had to keep its own progressive-disclosure widget:
|
||||
- The length premise did not hold. Merge's median help is ~103 characters — identical to Scheduling and SHORTER than Appearance (~168) and General (~123), both of which show help through the "?" without becoming a wall of prose.
|
||||
- Progressive disclosure is not lost, only unified. The tip still defers the copy visually; it just does so through one section-agnostic control instead of a Merge-only "▸ More details" summary. Merge was the last holdout, and the split was visible: 17 disclosures here against 2 tips.
|
||||
- No copy was deleted or reworded. `SettingsHelpTip` takes `ReactNode`, so the rows composing help from several `t()` fragments interleaved with `<code>`/`<strong>` (`mergeIntegrationWorktree`, `mergeConflictStrategy`, `postMergeAuditMode`, `commitAuthorName`, `commitAuthorEmail`) pass it through verbatim — the constraint that once forced them to a single-string descriptor `help` does not apply to the tip.
|
||||
The tip is a SIBLING of the `<label>`, never a child: a nested `<button>` swallows the label's click-to-focus and is invalid markup.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Most of this section deliberately keeps its bespoke markup, because Merge is not a section of plain label+control+help rows:
|
||||
- `mergeIntegrationWorktree` renders a live warning banner adjacent to its control that the shared primitive has no slot for, and its rich multi-fragment help rules out a single-string descriptor `help`; same for `mergeConflictStrategy`, `postMergeAuditMode`, `commitAuthorName`, and `commitAuthorEmail`.
|
||||
- `integrationBranch` and the push remote/branch pair are custom dropdown+Custom…-escape-hatch widgets, not plain selects.
|
||||
- `planApprovalMode` keeps its `data-testid="plan-approval-mode-select"`, which the primitives have no slot for and MergeSection.legacy-automerge-cleanup.test.tsx reads.
|
||||
- `testMode` is declared in BOTH `DEFAULT_GLOBAL_SETTINGS` and `DEFAULT_PROJECT_SETTINGS`, so its scope is ambiguous and no badge can be stamped honestly.
|
||||
- The legacy auto-merge stamp cleanup panel is a report-and-trigger card, not a setting.
|
||||
*/
|
||||
export interface MergeSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
integrationBranchOptions: string[];
|
||||
integrationBranchCustomMode: boolean;
|
||||
setIntegrationBranchCustomMode: (value: boolean) => void;
|
||||
@@ -60,7 +83,7 @@ export interface MergeSectionProps extends SectionBaseProps {
|
||||
gitRemoteOptions?: string[];
|
||||
projectId?: string;
|
||||
}
|
||||
export function MergeSection({ scopeBanner, form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, gitRemoteOptions = [], projectId, }: MergeSectionProps) {
|
||||
export function MergeSection({ form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, gitRemoteOptions = [], projectId, }: MergeSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const pushTarget = parsePushRemoteSetting(form.pushRemote);
|
||||
const [pushBranchOptions, setPushBranchOptions] = useState<string[]>([]);
|
||||
@@ -121,15 +144,13 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
}
|
||||
};
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.merge.merge", "Merge")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoMerge" className="checkbox-label">
|
||||
<input id="autoMerge" type="checkbox" checked={form.autoMerge} onChange={(e) => setForm((f) => ({ ...f, autoMerge: e.target.checked }))}/>{t("settings.merge.autoMergeCompletedTasks", " Auto-merge completed tasks ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.whenEnabledTasksThatPassReviewAreAutomatically", "When enabled, tasks that pass review are automatically merged into the main branch. Default: enabled.")}</small>
|
||||
</details>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="autoMerge" className="checkbox-label">
|
||||
<input id="autoMerge" type="checkbox" checked={form.autoMerge} onChange={(e) => setForm((f) => ({ ...f, autoMerge: e.target.checked }))}/>{t("settings.merge.autoMergeCompletedTasks", " Auto-merge completed tasks ")}</label>
|
||||
<SettingsHelpTip settingKey="autoMerge">{t("settings.merge.whenEnabledTasksThatPassReviewAreAutomatically", "When enabled, tasks that pass review are automatically merged into the main branch. Default: enabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/*
|
||||
@@ -139,7 +160,10 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
FNXC:PlanApproval 2026-07-04-00:00:
|
||||
FN-7557: auto-approve-all is now the project default (previously workflow), so the select fallback and "(default)" label marker move to the auto-approve option to keep the dropdown truthful.
|
||||
*/}
|
||||
<label htmlFor="planApprovalMode">{t("settings.merge.planApprovalMode", "Plan approval mode")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="planApprovalMode">{t("settings.merge.planApprovalMode", "Plan approval mode")}</label>
|
||||
<SettingsHelpTip settingKey="planApprovalMode">{t("settings.merge.planApprovalModeHelp", "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="planApprovalMode" className="select" value={form.planApprovalMode ?? "auto-approve-all"} onChange={(e) => {
|
||||
const nextMode = e.target.value as Settings["planApprovalMode"];
|
||||
setForm((f) => ({ ...f, planApprovalMode: nextMode }));
|
||||
@@ -148,23 +172,26 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value="auto-approve-all">{t("settings.merge.planApprovalModeAutoApproveAll", "Auto-approve all tasks (default)")}</option>
|
||||
<option value="require-all">{t("settings.merge.planApprovalModeRequireAll", "Require approval for all tasks")}</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.planApprovalModeHelp", "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.")}</small>
|
||||
</details>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxAutoMergeRetries">{t("settings.merge.autoMergeConflictRetries", "Auto-merge conflict retries")}</label>
|
||||
{/*
|
||||
FNXC:AutoMergeRetries 2026-06-17-04:20:
|
||||
Operators need a merge-section control for maxAutoMergeRetries so conflict-heavy projects can tune how many auto-resolution attempts occur before Fusion parks a task for human recovery. Invalid input falls back to 3 to preserve prior behavior.
|
||||
*/}
|
||||
<input id="maxAutoMergeRetries" type="number" min={1} step={1} value={form.maxAutoMergeRetries ?? 3} onChange={(e) => setForm((f) => ({
|
||||
{/*
|
||||
FNXC:AutoMergeRetries 2026-06-17-04:20:
|
||||
Operators need a merge-section control for maxAutoMergeRetries so conflict-heavy projects can tune how many auto-resolution attempts occur before Fusion parks a task for human recovery. Invalid input falls back to 3 to preserve prior behavior.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "maxAutoMergeRetries",
|
||||
label: t("settings.merge.autoMergeConflictRetries", "Auto-merge conflict retries"),
|
||||
help: t("settings.merge.positiveIntegerRetryCapForAutoMergeConflict", "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
step: 1,
|
||||
}}
|
||||
value={form.maxAutoMergeRetries ?? 3}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
maxAutoMergeRetries: e.target.value === "" ? undefined : resolveMaxAutoMergeRetriesForMergeForm(e.target.value),
|
||||
}))}/>
|
||||
<small>{t("settings.merge.positiveIntegerRetryCapForAutoMergeConflict", "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.")}</small>
|
||||
</div>
|
||||
maxAutoMergeRetries: v === null ? undefined : resolveMaxAutoMergeRetriesForMergeForm(v),
|
||||
}))}
|
||||
/>
|
||||
<div className="form-group" data-testid="legacy-automerge-stamp-cleanup-panel">
|
||||
<h5 className="settings-section-heading">{t("settings.merge.legacyAutoMergeStampCleanup", "Legacy auto-merge stamp cleanup")}</h5>
|
||||
<small>{t("settings.merge.findsInReviewTasksWhoseAutoMergeValue", " Finds in-review tasks whose auto-merge value came from the legacy review-entry stamp. Dry-run is automatic; applying delegates to the store cleanup and preserves genuine per-task overrides. ")}</small>
|
||||
@@ -183,57 +210,65 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
{legacyStampError ? <small className="settings-error" role="alert">{legacyStampError}</small> : null}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergerMode">{t("settings.merge.aIMerge", "AI merge")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="mergerMode">{t("settings.merge.aIMerge", "AI merge")}</label>
|
||||
<SettingsHelpTip settingKey="mergerMode">{t("settings.merge.aIModeMergesTheTaskBranchIntoAn", " AI mode merges the task branch into an isolated clean-room checkout at the target branch's tip, has an AI reviewer audit the squash (with corrective retries \u2014 advisory concerns land with a logged warning, an unfixable correctness concern hard-fails), then fast-forwards the target branch and syncs your local checkout (AI reconciles a conflicting restore). Each task merges to its own target branch, or the default integration branch. ")}<strong>{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}</strong></SettingsHelpTip>
|
||||
</div>
|
||||
<select id="mergerMode" className="select" value={form.merger?.mode ?? "ai"} onChange={(e) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))}>
|
||||
<option value="ai">{t("settings.merge.aIMergeDefaultAIMergesInAClean", "AI merge (default) \u2014 AI merges in a clean room, an AI reviewer audits with retries, then lands")}</option>
|
||||
<option value="deterministic">{t("settings.merge.deterministicLegacyRebaseConflictStrategyAuditPipeline", "Deterministic (legacy) \u2014 rebase / conflict-strategy / audit pipeline")}</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.aIModeMergesTheTaskBranchIntoAn", " AI mode merges the task branch into an isolated clean-room checkout at the target branch's tip, has an AI reviewer audit the squash (with corrective retries \u2014 advisory concerns land with a logged warning, an unfixable correctness concern hard-fails), then fast-forwards the target branch and syncs your local checkout (AI reconciles a conflicting restore). Each task merges to its own target branch, or the default integration branch. ")}<strong>{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}</strong>
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
{(form.merger?.mode ?? "ai") === "ai" && (<>
|
||||
{/* FNXC:AIMerge 2026-07-15-17:35: Dotted descriptor key because this is a leaf of the nested project-scoped `merger` blob, not a top-level settings field; the row's anchor and control id follow the same `merger.maxReviewPasses` path the settings blob uses. */}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "merger.maxReviewPasses",
|
||||
label: t("settings.merge.maxAIReviewPasses", "Max AI review passes"),
|
||||
help: t("settings.merge.aICorrectiveRoundsBeforeLandingTheBestResult", "AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model."),
|
||||
scope: "project",
|
||||
min: 0,
|
||||
max: 10,
|
||||
}}
|
||||
value={form.merger?.maxReviewPasses ?? 3}
|
||||
onChange={(v) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: v ?? undefined } }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergerMaxReviewPasses">{t("settings.merge.maxAIReviewPasses", "Max AI review passes")}</label>
|
||||
<input id="mergerMaxReviewPasses" type="number" min={0} max={10} value={form.merger?.maxReviewPasses ?? 3} onChange={(e) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))}/>
|
||||
<small>{t("settings.merge.aICorrectiveRoundsBeforeLandingTheBestResult", "AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
|
||||
<input id="mergerAllowDirtyLocalCheckoutSync" type="checkbox" checked={form.merger?.allowDirtyLocalCheckoutSync === true} onChange={(e) => setForm((f) => ({
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
|
||||
<input id="mergerAllowDirtyLocalCheckoutSync" type="checkbox" checked={form.merger?.allowDirtyLocalCheckoutSync === true} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
merger: { ...(f.merger ?? {}), allowDirtyLocalCheckoutSync: e.target.checked },
|
||||
}))}/>{t("settings.merge.allowAIMergeToSyncADirtyChecked", " Allow AI merge to sync a dirty checked-out integration branch ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.dangerousCompatibilityEscapeHatchLeaveOffUnlessYou", " Dangerous compatibility escape hatch \u2014 restores the legacy stash \u2192 fast-forward \u2192 restore behavior when your checked-out integration branch has unrelated local edits. When off, AI merge blocks before advancing the branch so dirty project-root edits cannot contaminate a completed merge. Default: enabled (new/unconfigured projects sync a dirty checkout). ")}</small>
|
||||
</details>
|
||||
<SettingsHelpTip settingKey="merger.allowDirtyLocalCheckoutSync">{t("settings.merge.dangerousCompatibilityEscapeHatchLeaveOffUnlessYou", " Dangerous compatibility escape hatch \u2014 restores the legacy stash \u2192 fast-forward \u2192 restore behavior when your checked-out integration branch has unrelated local edits. When off, AI merge blocks before advancing the branch so dirty project-root edits cannot contaminate a completed merge. Default: enabled (new/unconfigured projects sync a dirty checkout). ")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="testMode" className="checkbox-label">
|
||||
<input id="testMode" type="checkbox" checked={form.testMode === true} onChange={(e) => setForm((f) => ({ ...f, testMode: e.target.checked }))}/>{t("settings.merge.enableTestMode", " Enable test mode ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.forcesAllAILanesToUseTheDeterministic", "Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost. No default \u2014 unset (disabled).")}</small>
|
||||
</details>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="testMode" className="checkbox-label">
|
||||
<input id="testMode" type="checkbox" checked={form.testMode === true} onChange={(e) => setForm((f) => ({ ...f, testMode: e.target.checked }))}/>{t("settings.merge.enableTestMode", " Enable test mode ")}</label>
|
||||
<SettingsHelpTip settingKey="testMode">{t("settings.merge.forcesAllAILanesToUseTheDeterministic", "Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost. No default \u2014 unset (disabled).")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
<MovedSettingsStub message={t("settings.movedStub.reviewVerification", "Review, verification auto-fix, and scope-enforcement settings now live on the workflow.")} onOpenWorkflowSettings={onOpenWorkflowSettings}/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergeStrategy">{t("settings.merge.autoCompletionMode", "Auto-completion mode")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="mergeStrategy">{t("settings.merge.autoCompletionMode", "Auto-completion mode")}</label>
|
||||
<SettingsHelpTip settingKey="mergeStrategy">{t("settings.merge.controlsWhatHappensAfterATaskReachesIn", " Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="mergeStrategy" value={form.mergeStrategy || "direct"} onChange={(e) => setForm((f) => ({ ...f, mergeStrategy: e.target.value as Settings["mergeStrategy"] }))}>
|
||||
<option value="direct">{t("settings.merge.directMergeIntoTheCurrentBranch", "Direct merge into the current branch (default)")}</option>
|
||||
<option value="pull-request">{t("settings.merge.createMonitorAndMergeAGitHubPullRequest", "Create, monitor, and merge a GitHub pull request")}</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.controlsWhatHappensAfterATaskReachesIn", " Controls what happens after a task reaches In Review. Direct mode merges into the current branch locally. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR. ")}</small>
|
||||
</details>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="integrationBranch">{t("settings.merge.integrationBranch", "Integration branch")}</label>
|
||||
<SettingsHelpTip settingKey="integrationBranch">{t("settings.merge.theCanonicalBranchFusionMergesTasksIntoAnd", " No default \u2014 unset (auto-detect). The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ")}<em>{t("settings.merge.autoDetect", "auto-detect")}</em>{t("settings.merge.toResolveViaTheStandardCascade", " to resolve via the standard cascade (")}<code>integrationBranch</code>{t("settings.merge.legacy", " \u2192 legacy ")}<code>baseBranch</code> →
|
||||
<code>origin/HEAD</code>{t("settings.merge.symbolicRefFallback", " symbolic ref \u2192 fallback ")}<code>main</code>{t("settings.merge.pickALocalBranchFromTheDropdownCommon", "). Pick a local branch from the dropdown \u2014 common integration names like ")}<code>main</code>,
|
||||
<code>master</code>, <code>trunk</code>{t("settings.merge.and", ", and ")}<code>develop</code>{t("settings.merge.areListedFirstOrChoose", " are listed first \u2014 or choose ")}<em>{t("settings.merge.custom", "Custom\u2026")}</em>{t("settings.merge.toTypeABranchThatDoesnAposT", " to type a branch that doesn't exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
{(() => {
|
||||
const currentValue = form.integrationBranch ?? "";
|
||||
const valueIsKnown = currentValue.length > 0 && integrationBranchOptions.includes(currentValue);
|
||||
@@ -271,16 +306,14 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value={CUSTOM}>{t("settings.merge.custom", "Custom\u2026")}</option>
|
||||
</select>);
|
||||
})()}
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.theCanonicalBranchFusionMergesTasksIntoAnd", " No default \u2014 unset (auto-detect). The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ")}<em>{t("settings.merge.autoDetect", "auto-detect")}</em>{t("settings.merge.toResolveViaTheStandardCascade", " to resolve via the standard cascade (")}<code>integrationBranch</code>{t("settings.merge.legacy", " \u2192 legacy ")}<code>baseBranch</code> →
|
||||
<code>origin/HEAD</code>{t("settings.merge.symbolicRefFallback", " symbolic ref \u2192 fallback ")}<code>main</code>{t("settings.merge.pickALocalBranchFromTheDropdownCommon", "). Pick a local branch from the dropdown \u2014 common integration names like ")}<code>main</code>,
|
||||
<code>master</code>, <code>trunk</code>{t("settings.merge.and", ", and ")}<code>develop</code>{t("settings.merge.areListedFirstOrChoose", " are listed first \u2014 or choose ")}<em>{t("settings.merge.custom", "Custom\u2026")}</em>{t("settings.merge.toTypeABranchThatDoesnAposT", " to type a branch that doesn't exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ")}</small>
|
||||
</details>
|
||||
</div>
|
||||
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="directMergeCommitStrategy">{t("settings.merge.directMergeCommitRouting", "Direct merge commit routing")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="directMergeCommitStrategy">{t("settings.merge.directMergeCommitRouting", "Direct merge commit routing")}</label>
|
||||
<SettingsHelpTip settingKey="directMergeCommitStrategy">{t("settings.merge.autoKeepsTodayAposSSquashBehaviorFor", " Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with ")}<code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="directMergeCommitStrategy" className="select" value={form.directMergeCommitStrategy ?? "always-squash"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
directMergeCommitStrategy: e.target.value as "auto" | "always-squash" | "always-rebase",
|
||||
@@ -289,14 +322,12 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value="always-squash">{t("settings.merge.alwaysSquashDirectMerges", "Always squash direct merges (default)")}</option>
|
||||
<option value="always-rebase">{t("settings.merge.alwaysPreserveDirectMergeCommitHistory", "Always preserve direct-merge commit history")}</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.autoKeepsTodayAposSSquashBehaviorFor", " Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with ")}<code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergeIntegrationWorktree">{t("settings.merge.integrationWorktree", "Integration worktree")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="mergeIntegrationWorktree">{t("settings.merge.integrationWorktree", "Integration worktree")}</label>
|
||||
<SettingsHelpTip settingKey="mergeIntegrationWorktree">{t("settings.merge.autoMergeRunsInTheTaskWorktreeBy", " Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="mergeIntegrationWorktree" className="select" value={form.mergeIntegrationWorktree ?? "reuse-task-worktree"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
mergeIntegrationWorktree: e.target.value as Settings["mergeIntegrationWorktree"],
|
||||
@@ -304,12 +335,14 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value="reuse-task-worktree">{t("settings.merge.reuseTaskWorktreeDefault", "Reuse task worktree (default)")}</option>
|
||||
<option value="cwd-main">{t("settings.merge.useProjectRootLegacy", "Use project root (legacy)")}</option>
|
||||
</select>
|
||||
<small>{t("settings.merge.autoMergeRunsInTheTaskWorktreeBy", " Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. ")}</small>
|
||||
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (<div className="settings-warning-banner" role="alert" aria-live="polite" data-testid="merge-integration-worktree-warning">
|
||||
<strong>{t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}</strong>{" "}{t("settings.merge.autoMergeWillRunRebaseConflictResolutionAnd", " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user's checked-out integration-branch worktree) instead of the task worktree. Fusion assumes that directory is already on the integration branch and clean; if it isn't, merges may fail or touch the user's working tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless you have a specific reason to opt in (FN-5348). ")}</div>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergeAdvanceAutoSync">{t("settings.merge.autoSyncProjectCheckoutAfterMerge", "Auto-sync project checkout after merge")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="mergeAdvanceAutoSync">{t("settings.merge.autoSyncProjectCheckoutAfterMerge", "Auto-sync project checkout after merge")}</label>
|
||||
<SettingsHelpTip settingKey="mergeAdvanceAutoSync">{t("settings.merge.afterFusionAdvancesTheIntegrationBranchRefThe", " After Fusion advances the integration branch ref, the merger can auto-sync other worktrees still checked out on that branch (typically your project-root checkout). ")}<code>Stash + fast-forward</code>{t("settings.merge.snapshotsRealLocalEditsAsAPatchAgainst", " snapshots real local edits as a patch against the previous tip, snaps the worktree to the new tip, then reapplies the patch \u2014 untracked files that collide with newly-tracked paths are left in a temp dir for manual recovery. ")}<code>Fast-forward only</code>{t("settings.merge.snapsCleanlyWhenTheWorktreeHasNoEdits", " snaps cleanly when the worktree has no edits and skips otherwise. ")}<code>Off</code>{t("settings.merge.isTheLegacyBehavior", " is the legacy behavior: ")}<code>git status</code>{t("settings.merge.inYourProjectRootWillShowTheNew", " in your project root will show the new commits inverted as "staged changes" until you pull manually. Only applies to direct merges. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="mergeAdvanceAutoSync" className="select" value={form.mergeAdvanceAutoSync ?? "stash-and-ff"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
mergeAdvanceAutoSync: e.target.value as "off" | "ff-only" | "stash-and-ff",
|
||||
@@ -318,142 +351,108 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value="ff-only">{t("settings.merge.fastForwardOnlySkipDirtyWorktrees", "Fast-forward only \u2014 skip dirty worktrees")}</option>
|
||||
<option value="off">{t("settings.merge.offLeaveTheProjectRootStaleLegacyBehavior", "Off \u2014 leave the project root stale (legacy behavior)")}</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.afterFusionAdvancesTheIntegrationBranchRefThe", " After Fusion advances the integration branch ref, the merger can auto-sync other worktrees still checked out on that branch (typically your project-root checkout). ")}<code>Stash + fast-forward</code>{t("settings.merge.snapshotsRealLocalEditsAsAPatchAgainst", " snapshots real local edits as a patch against the previous tip, snaps the worktree to the new tip, then reapplies the patch \u2014 untracked files that collide with newly-tracked paths are left in a temp dir for manual recovery. ")}<code>Fast-forward only</code>{t("settings.merge.snapsCleanlyWhenTheWorktreeHasNoEdits", " snaps cleanly when the worktree has no edits and skips otherwise. ")}<code>Off</code>{t("settings.merge.isTheLegacyBehavior", " is the legacy behavior: ")}<code>git status</code>{t("settings.merge.inYourProjectRootWillShowTheNew", " in your project root will show the new commits inverted as "staged changes" until you pull manually. Only applies to direct merges. ")}</small>
|
||||
</details>
|
||||
</div>
|
||||
</>)}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.merge.gitHubAuthentication", "GitHub Authentication")}</h4>
|
||||
{/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The GitHub Authentication and GitLab Authentication blocks moved to "Source Control · Project" (SourceControlSection.tsx), joining the GitHub tracking + GitLab URL settings that were in General. Merge owns the landing strategy; how Fusion authenticates to a forge is a source-control concern that Merge only consumed.
|
||||
This section's GitLab auth disclosure carried a SECOND `gitlabEnabled` toggle (id `mergeGitlabEnabled`) writing the same key as General's — removing it here is what resolves that duplicate, so do not reintroduce a forge auth control in Merge.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="githubAuthMode">{t("settings.merge.gitHubAuthMode", "GitHub auth mode")}</label>
|
||||
<select id="githubAuthMode" className="select" value={form.githubAuthMode ?? "gh-cli"} onChange={(e) => setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))}>
|
||||
<option value="gh-cli">{t("settings.merge.gitHubCLIGhAuth", "GitHub CLI (gh auth) (default)")}</option>
|
||||
<option value="token">{t("settings.merge.personalAccessToken", "Personal access token")}</option>
|
||||
</select>
|
||||
</div>
|
||||
{(form.githubAuthMode ?? "gh-cli") === "token" && (<div className="form-group">
|
||||
<label htmlFor="githubAuthToken">{t("settings.merge.gitHubPersonalAccessToken", "GitHub personal access token")}</label>
|
||||
<input id="githubAuthToken" type="password" className="input" value={form.githubAuthToken ?? ""} onChange={(e) => setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))}/>
|
||||
<small>{t("settings.merge.githubAuthTokenHint", "No default \u2014 unset.")}</small>
|
||||
</div>)}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}</h4>
|
||||
{/**
|
||||
* FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
* FN-7453 makes project GitLab auth controls collapsible and governed by the same project-scoped enable switch as URL settings. Disabling GitLab preserves saved tokens but blocks outbound API side effects before auth validation.
|
||||
*/}
|
||||
<details className="settings-gitlab-disclosure" data-testid="project-gitlab-authentication-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="mergeGitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="mergeGitlabEnabled" type="checkbox" checked={form.gitlabEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, gitlabEnabled: e.target.checked }))}/>
|
||||
{t("settings.merge.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{form.gitlabEnabled === false ? t("settings.merge.gitLabDisabledHint", "GitLab comments, close/reopen, import fetches, and refresh operations are disabled. Saved tokens remain stored for re-enable.") : t("settings.merge.gitLabAuthDetails", "Fusion uses GitLab REST API token authentication with the PRIVATE-TOKEN header. Leave the token blank to clear the project override and fall back to a configured global GitLab token or GITLAB_TOKEN where available. No default — unset (unset behaves as enabled until explicitly disabled).")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={form.gitlabEnabled === false}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabAuthTokenType">{t("settings.merge.gitLabTokenType", "GitLab token type")}</label>
|
||||
<select id="gitlabAuthTokenType" className="select" value={form.gitlabAuthTokenType ?? "personal"} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthTokenType: e.target.value as "personal" | "project" | "group" }))}>
|
||||
<option value="personal">{t("settings.merge.gitLabPersonalAccessToken", "Personal access token (default)")}</option>
|
||||
<option value="project">{t("settings.merge.gitLabProjectAccessToken", "Project access token")}</option>
|
||||
<option value="group">{t("settings.merge.gitLabGroupAccessToken", "Group access token")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gitlabAuthToken">{t("settings.merge.gitLabAccessToken", "GitLab access token")}</label>
|
||||
<input id="gitlabAuthToken" type="password" className="input" autoComplete="off" value={form.gitlabAuthToken ?? ""} disabled={form.gitlabEnabled === false} onChange={(e) => setForm((f) => ({ ...f, gitlabAuthToken: e.target.value || undefined }))}/>
|
||||
<small className="settings-description">{t("settings.merge.gitLabAuthTokenHint", "Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
|
||||
<input id="includeTaskIdInCommit" type="checkbox" checked={form.includeTaskIdInCommit !== false} onChange={(e) => setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))}/>{t("settings.merge.includeTaskIDInCommitScope", " Include task ID in commit scope ")}</label>
|
||||
<SettingsHelpTip settingKey="includeTaskIdInCommit">{t("settings.merge.whenDisabledMergeCommitMessagesOmitTheTask", "When disabled, merge commit messages omit the task ID from the scope (e.g. ")}<code>feat: ...</code>{t("settings.merge.insteadOf", " instead of ")}<code>feat(KB-001): ...</code>{t("settings.merge.includeTaskIdInCommitDefault", "). Default: enabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
</details>
|
||||
<div className="form-group">
|
||||
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
|
||||
<input id="includeTaskIdInCommit" type="checkbox" checked={form.includeTaskIdInCommit !== false} onChange={(e) => setForm((f) => ({ ...f, includeTaskIdInCommit: e.target.checked }))}/>{t("settings.merge.includeTaskIDInCommitScope", " Include task ID in commit scope ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.whenDisabledMergeCommitMessagesOmitTheTask", "When disabled, merge commit messages omit the task ID from the scope (e.g. ")}<code>feat: ...</code>{t("settings.merge.insteadOf", " instead of ")}<code>feat(KB-001): ...</code>{t("settings.merge.includeTaskIdInCommitDefault", "). Default: enabled.")}</small>
|
||||
</details>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="commitAuthorEnabled" className="checkbox-label">
|
||||
<input id="commitAuthorEnabled" type="checkbox" checked={form.commitAuthorEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))}/>{t("settings.merge.addFusionAsCoAuthorOnCommits", " Add Fusion as co-author on commits ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.whenEnabledCommitsMadeByFusionKeepYour", " When enabled, commits made by Fusion keep your git identity as the primary author and append a ")}<code>Co-authored-by</code>{t("settings.merge.trailerCreditingFusionRecognizedByGitHubForShared", " trailer crediting Fusion (recognized by GitHub for shared attribution). Default: enabled. ")}</small>
|
||||
</details>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="commitAuthorEnabled" className="checkbox-label">
|
||||
<input id="commitAuthorEnabled" type="checkbox" checked={form.commitAuthorEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, commitAuthorEnabled: e.target.checked }))}/>{t("settings.merge.addFusionAsCoAuthorOnCommits", " Add Fusion as co-author on commits ")}</label>
|
||||
<SettingsHelpTip settingKey="commitAuthorEnabled">{t("settings.merge.whenEnabledCommitsMadeByFusionKeepYour", " When enabled, commits made by Fusion keep your git identity as the primary author and append a ")}<code>Co-authored-by</code>{t("settings.merge.trailerCreditingFusionRecognizedByGitHubForShared", " trailer crediting Fusion (recognized by GitHub for shared attribution). Default: enabled. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.commitAuthorEnabled !== false && (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="commitAuthorName">{t("settings.merge.coAuthorName", "Co-author Name")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="commitAuthorName">{t("settings.merge.coAuthorName", "Co-author Name")}</label>
|
||||
<SettingsHelpTip settingKey="commitAuthorName">{t("settings.merge.nameUsedInThe", "Name used in the ")}<code>Co-authored-by</code>{t("settings.merge.trailer", " trailer. Default: Fusion.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="commitAuthorName" type="text" value={form.commitAuthorName ?? ""} placeholder={t("settings.merge.fusion", "Fusion")} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
commitAuthorName: e.target.value || undefined,
|
||||
}))}/>
|
||||
<small>{t("settings.merge.nameUsedInThe", "Name used in the ")}<code>Co-authored-by</code>{t("settings.merge.trailer", " trailer. Default: Fusion.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="commitAuthorEmail">{t("settings.merge.coAuthorEmail", "Co-author Email")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="commitAuthorEmail">{t("settings.merge.coAuthorEmail", "Co-author Email")}</label>
|
||||
<SettingsHelpTip settingKey="commitAuthorEmail">{t("settings.merge.emailUsedInThe", "Email used in the ")}<code>Co-authored-by</code>{t("settings.merge.trailerEmail", " trailer. Default: noreply@runfusion.ai.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="commitAuthorEmail" type="email" value={form.commitAuthorEmail ?? ""} placeholder={t("settings.merge.noreplyRunfusionAi", "noreply@runfusion.ai")} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
commitAuthorEmail: e.target.value || undefined,
|
||||
}))}/>
|
||||
<small>{t("settings.merge.emailUsedInThe", "Email used in the ")}<code>Co-authored-by</code>{t("settings.merge.trailerEmail", " trailer. Default: noreply@runfusion.ai.")}</small>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoResolveConflicts" className="checkbox-label">
|
||||
<input id="autoResolveConflicts" type="checkbox" checked={form.autoResolveConflicts !== false} onChange={(e) => setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))}/>{t("settings.merge.autoResolveConflictsInLockFilesAndGenerated", " Auto-resolve conflicts in lock files and generated files ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.whenEnabledLockFilesPackageLockJsonPnpm", "When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review. Default: enabled.")}</small>
|
||||
</details>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="autoResolveConflicts" className="checkbox-label">
|
||||
<input id="autoResolveConflicts" type="checkbox" checked={form.autoResolveConflicts !== false} onChange={(e) => setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))}/>{t("settings.merge.autoResolveConflictsInLockFilesAndGenerated", " Auto-resolve conflicts in lock files and generated files ")}</label>
|
||||
<SettingsHelpTip settingKey="autoResolveConflicts">{t("settings.merge.whenEnabledLockFilesPackageLockJsonPnpm", "When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review. Default: enabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
{(form.merger?.mode ?? "ai") !== "ai" && (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="smartConflictResolution" className="checkbox-label">
|
||||
<input id="smartConflictResolution" type="checkbox" checked={form.smartConflictResolution !== false} onChange={(e) => setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))}/>{t("settings.merge.smartConflictResolution", " Smart conflict resolution ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.whenEnabledLockFilesPackageLockJsonPnpm2", "When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review. Default: enabled.")}</small>
|
||||
</details>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="smartConflictResolution" className="checkbox-label">
|
||||
<input id="smartConflictResolution" type="checkbox" checked={form.smartConflictResolution !== false} onChange={(e) => setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))}/>{t("settings.merge.smartConflictResolution", " Smart conflict resolution ")}</label>
|
||||
<SettingsHelpTip settingKey="smartConflictResolution">{t("settings.merge.whenEnabledLockFilesPackageLockJsonPnpm2", "When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review. Default: enabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergeConflictStrategy">{t("settings.merge.conflictFallbackStrategy", "Conflict Fallback Strategy")}</label>
|
||||
<select id="mergeConflictStrategy" value={form.mergeConflictStrategy ?? "smart-prefer-main"} onChange={(e) => setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))}>
|
||||
<option value="smart-prefer-main">{t("settings.merge.smartPreferMainOnFallbackFetchFfOrigin", "Smart, prefer main on fallback \u2014 fetch+ff origin \u2192 AI \u2192 auto-resolve \u2192 -X ours (default; protects just-merged sibling work)")}</option>
|
||||
<option value="smart-prefer-branch">{t("settings.merge.smartPreferTaskOnFallbackFetchFfOrigin", "Smart, prefer task on fallback \u2014 fetch+ff origin \u2192 AI \u2192 auto-resolve \u2192 -X theirs (legacy \"smart\" behavior; task branch wins)")}</option>
|
||||
<option value="ai-only">{t("settings.merge.aIOnlyAIAutoResolveAIRetryNever", "AI only \u2014 AI \u2192 auto-resolve \u2192 AI retry; never silently pick a side")}</option>
|
||||
<option value="abort">{t("settings.merge.abortOneAIAttemptRequireManualResolutionIf", "Abort \u2014 one AI attempt; require manual resolution if it fails")}</option>
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.both", " Both ")}<strong>{t("settings.merge.smart", "Smart")}</strong>{t("settings.merge.optionsStartWithABestEffort", " options start with a best-effort ")}<code>git fetch</code>{t("settings.merge.fastForwardOfLocalMainFrom", " + fast-forward of local main from ")}<code>origin</code>{t("settings.merge.soAFreshlyPushedSiblingCommitDoesntGet", " (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the ")}<em>{t("settings.merge.finalFallback", "final fallback")}</em>:
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="mergeConflictStrategy">{t("settings.merge.conflictFallbackStrategy", "Conflict Fallback Strategy")}</label>
|
||||
<SettingsHelpTip settingKey="mergeConflictStrategy">{t("settings.merge.both", " Both ")}<strong>{t("settings.merge.smart", "Smart")}</strong>{t("settings.merge.optionsStartWithABestEffort", " options start with a best-effort ")}<code>git fetch</code>{t("settings.merge.fastForwardOfLocalMainFrom", " + fast-forward of local main from ")}<code>origin</code>{t("settings.merge.soAFreshlyPushedSiblingCommitDoesntGet", " (so a freshly-pushed sibling commit doesn't get clobbered), then run an AI agent, then auto-resolve handles lock/generated/trivial files. They differ only in the ")}<em>{t("settings.merge.finalFallback", "final fallback")}</em>:
|
||||
{" "}
|
||||
<strong>{t("settings.merge.smartPreferMain", "Smart, prefer main")}</strong>{t("settings.merge.uses", " uses ")}<code>-X ours</code>{t("settings.merge.soMainWinsProtectsJustMergedSiblingWork", " so main wins \u2014 protects just-merged sibling work and is the new default. ")}{" "}
|
||||
<strong>{t("settings.merge.smartPreferTask", "Smart, prefer task")}</strong>{t("settings.merge.uses", " uses ")}<code>-X theirs</code>{t("settings.merge.soTheTaskBranchWinsFastButCan", " so the task branch wins \u2014 fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression). ")}{" "}
|
||||
<strong>{t("settings.merge.aIOnly", "AI only")}</strong>{t("settings.merge.retriesTheAIAgentRatherThanAutoPicking", " retries the AI agent rather than auto-picking a side. ")}{" "}
|
||||
<strong>{t("settings.merge.abort", "Abort")}</strong>{t("settings.merge.stopsAfterTheFirstAIAttemptAndWaits", " stops after the first AI attempt and waits for a human. ")}{" "}
|
||||
<em>{t("settings.merge.legacy2", "Legacy ")}<code>"smart"</code>{t("settings.merge.and2", " and ")}<code>"prefer-main"</code>{t("settings.merge.valuesFromOlderSettingsAreMigratedAutomatically", " values from older settings are migrated automatically.")}</em>
|
||||
</small>
|
||||
</details>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mergeStrategyOverlapBehavior">{t("settings.merge.smartPreferMainOverlapGuard", "Smart Prefer Main Overlap Guard")}</label>
|
||||
<select id="mergeStrategyOverlapBehavior" value={form.mergeStrategyOverlapBehavior ?? "flip-to-prefer-branch"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
mergeStrategyOverlapBehavior: e.target.value as "flip-to-prefer-branch" | "warn-only" | "ignore",
|
||||
}))}>
|
||||
<option value="flip-to-prefer-branch">{t("settings.merge.flipOverlappingFilesToPreferTheTaskBranch", "Flip overlapping files to prefer the task branch (default)")}</option>
|
||||
<option value="warn-only">{t("settings.merge.warnOnlyKeepLegacyMainWinsFallback", "Warn only \u2014 keep legacy main-wins fallback")}</option>
|
||||
<option value="ignore">{t("settings.merge.ignoreOverlapDetectionPreserveLegacyBehavior", "Ignore overlap detection \u2014 preserve legacy behavior")}</option>
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="mergeConflictStrategy" value={form.mergeConflictStrategy ?? "smart-prefer-main"} onChange={(e) => setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart-prefer-main" | "smart-prefer-branch" | "ai-only" | "abort" }))}>
|
||||
<option value="smart-prefer-main">{t("settings.merge.smartPreferMainOnFallbackFetchFfOrigin", "Smart, prefer main on fallback \u2014 fetch+ff origin \u2192 AI \u2192 auto-resolve \u2192 -X ours (default; protects just-merged sibling work)")}</option>
|
||||
<option value="smart-prefer-branch">{t("settings.merge.smartPreferTaskOnFallbackFetchFfOrigin", "Smart, prefer task on fallback \u2014 fetch+ff origin \u2192 AI \u2192 auto-resolve \u2192 -X theirs (legacy \"smart\" behavior; task branch wins)")}</option>
|
||||
<option value="ai-only">{t("settings.merge.aIOnlyAIAutoResolveAIRetryNever", "AI only \u2014 AI \u2192 auto-resolve \u2192 AI retry; never silently pick a side")}</option>
|
||||
<option value="abort">{t("settings.merge.abortOneAIAttemptRequireManualResolutionIf", "Abort \u2014 one AI attempt; require manual resolution if it fails")}</option>
|
||||
</select>
|
||||
<small>{t("settings.merge.whenUsingSmartPreferMainAutomaticallyPreferThe", " When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. ")}</small>
|
||||
</div>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "mergeStrategyOverlapBehavior",
|
||||
label: t("settings.merge.smartPreferMainOverlapGuard", "Smart Prefer Main Overlap Guard"),
|
||||
help: t("settings.merge.whenUsingSmartPreferMainAutomaticallyPreferThe", " When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work. "),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "flip-to-prefer-branch", label: t("settings.merge.flipOverlappingFilesToPreferTheTaskBranch", "Flip overlapping files to prefer the task branch (default)") },
|
||||
{ value: "warn-only", label: t("settings.merge.warnOnlyKeepLegacyMainWinsFallback", "Warn only \u2014 keep legacy main-wins fallback") },
|
||||
{ value: "ignore", label: t("settings.merge.ignoreOverlapDetectionPreserveLegacyBehavior", "Ignore overlap detection \u2014 preserve legacy behavior") },
|
||||
],
|
||||
}}
|
||||
value={form.mergeStrategyOverlapBehavior ?? "flip-to-prefer-branch"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
mergeStrategyOverlapBehavior: v as "flip-to-prefer-branch" | "warn-only" | "ignore",
|
||||
}))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="postMergeAuditMode">{t("settings.merge.postMergeAuditMode", "Post-merge audit mode")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="postMergeAuditMode">{t("settings.merge.postMergeAuditMode", "Post-merge audit mode")}</label>
|
||||
<SettingsHelpTip settingKey="postMergeAuditMode">{t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}<strong>{t("settings.merge.warn", "Warn")}</strong>{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}<strong>{t("settings.merge.block", "Block")}</strong>{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}<strong>{t("settings.merge.off", "Off")}</strong>{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select className="select" id="postMergeAuditMode" value={form.postMergeAuditMode ?? "warn"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
|
||||
@@ -462,28 +461,28 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value="warn">{t("settings.merge.warnDefaultLogFindingsContinue", "Warn (default; log findings, continue)")}</option>
|
||||
<option value="off">{t("settings.merge.offSkipAudit", "Off (skip audit)")}</option>
|
||||
</select>
|
||||
<small>{t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}<strong>{t("settings.merge.warn", "Warn")}</strong>{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}<strong>{t("settings.merge.block", "Block")}</strong>{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}<strong>{t("settings.merge.off", "Off")}</strong>{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}</small>
|
||||
</div>
|
||||
</>)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
||||
<input id="pushAfterMerge" type="checkbox" checked={form.pushAfterMerge === true} onChange={(e) => setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))}/>{t("settings.merge.pushToRemoteAfterMerge", " Push to remote after merge ")}</label>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.whenEnabledTheMergedResultIsAutomaticallyPushed", "When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed. Default: disabled.")}</small>
|
||||
</details>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
||||
<input id="pushAfterMerge" type="checkbox" checked={form.pushAfterMerge === true} onChange={(e) => setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))}/>{t("settings.merge.pushToRemoteAfterMerge", " Push to remote after merge ")}</label>
|
||||
<SettingsHelpTip settingKey="pushAfterMerge">{t("settings.merge.whenEnabledTheMergedResultIsAutomaticallyPushed", "When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed. Default: disabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.pushAfterMerge && (gitRemoteOptions.length === 0 ? (<div className="form-group">
|
||||
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
|
||||
<SettingsHelpTip settingKey="pushRemote">{t("settings.merge.gitRemoteToPushToEGOrigin", "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="pushRemote" type="text" placeholder={t("settings.merge.origin", "origin")} value={form.pushRemote || ""} onChange={(e) => setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))}/>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.gitRemoteToPushToEGOrigin", "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".")}</small>
|
||||
</details>
|
||||
</div>) : (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
|
||||
<SettingsHelpTip settingKey="pushRemote">{t("settings.merge.gitRemoteThatMergedResultsArePushedTo", "Git remote that merged results are pushed to. Default: \"origin\".")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="pushRemote" className="select" value={pushTarget.remote} onChange={(e) => {
|
||||
// Capture eagerly: the deferred setForm updater must not read the
|
||||
// controlled select's value after React resets it on re-render.
|
||||
@@ -495,13 +494,12 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
{!gitRemoteOptions.includes(pushTarget.remote) && (<option value={pushTarget.remote}>{pushTarget.remote}</option>)}
|
||||
{gitRemoteOptions.map((name) => (<option key={name} value={name}>{name}</option>))}
|
||||
</select>
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.gitRemoteThatMergedResultsArePushedTo", "Git remote that merged results are pushed to. Default: \"origin\".")}</small>
|
||||
</details>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pushRemoteBranch">{t("settings.merge.pushTargetBranch", "Push target branch")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="pushRemoteBranch">{t("settings.merge.pushTargetBranch", "Push target branch")}</label>
|
||||
<SettingsHelpTip settingKey="pushRemoteBranch">{t("settings.merge.pushTargetBranchHelp", "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it).")}</SettingsHelpTip>
|
||||
</div>
|
||||
{(() => {
|
||||
const currentBranch = pushTarget.branch;
|
||||
const branchIsKnown = currentBranch.length > 0 && pushBranchOptions.includes(currentBranch);
|
||||
@@ -531,10 +529,6 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
||||
<option value={CUSTOM}>{t("settings.merge.custom", "Custom…")}</option>
|
||||
</select>);
|
||||
})()}
|
||||
<details className="settings-option-details">
|
||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||
<small>{t("settings.merge.pushTargetBranchHelp", "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it).")}</small>
|
||||
</details>
|
||||
</div>
|
||||
</>))}
|
||||
</>);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: The pricing hint named --font-size-xs but carried a raw 0.75rem fallback that no longer matches the token's 0.8rem value. The fallback is dropped so the declaration cannot silently render off-scale. */
|
||||
.model-pricing-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -58,7 +59,7 @@
|
||||
|
||||
.model-pricing-row--head {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* FNXC:SettingsStyling 2026-07-15-17:35: The stub's message and action used a raw 0.85rem that only coincidentally matched the control-label rung. Both now name --font-size-sm so the redirect stub stays visually consistent with the sections it replaces. */
|
||||
/* MovedSettingsStub (U9 / KTD-5) — redirect stub for hard-moved settings. */
|
||||
|
||||
.settings-moved-stub {
|
||||
@@ -12,14 +13,14 @@
|
||||
|
||||
.settings-moved-stub__message {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-moved-stub__action {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Search entries for the Node Routing section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim — search matches the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* `defaultNodeId` is deliberately absent: it is still a hand-rolled row (its NodeHealthDot has no slot in the shared select row), and the index must only carry keys a descriptor actually renders — a stale entry would scroll to an anchor that does not exist.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const nodeRoutingSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "node-routing",
|
||||
key: "unavailableNodePolicy",
|
||||
labelKey: "settings.nodeRouting.unavailableNodePolicy",
|
||||
labelFallback: "Unavailable Node Policy",
|
||||
helpKey: "settings.nodeRouting.unavailableNodePolicyHint",
|
||||
helpFallback: "Default: block execution.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The copy never says "offline" or "fallback" — the words operators reach for when a node is down — so both are indexed as genuine vocabulary gaps rather than restatements of the label.
|
||||
*/
|
||||
keywords: ["offline node", "fallback", "unreachable", "local execution"],
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { NodeInfo } from "../../../api";
|
||||
import { NodeHealthDot } from "../../NodeHealthDot";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import type { SettingsFormState, SetSettingsForm } from "./context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error", t: ReturnType<typeof useTranslation<"app">>["t"]): string {
|
||||
@@ -13,18 +13,24 @@ function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error
|
||||
return t("settings.nodeRouting.statusOffline", "Offline");
|
||||
}
|
||||
export interface NodeRoutingSectionProps {
|
||||
scopeBanner: ReactNode;
|
||||
form: SettingsFormState;
|
||||
setForm: SetSettingsForm;
|
||||
nodes: NodeInfo[];
|
||||
}
|
||||
export function NodeRoutingSection({ scopeBanner, form, setForm, nodes }: NodeRoutingSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Both routing settings are project-scoped (DEFAULT_PROJECT_SETTINGS), which is what the note below already tells the operator in prose; the per-row scope badge is the machine-readable form of that same claim.
|
||||
*/
|
||||
export function NodeRoutingSection({ form, setForm, nodes }: NodeRoutingSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.nodeRouting.nodeRouting", "Node Routing")}</h4>
|
||||
<p className="settings-section-description">{t("settings.nodeRouting.configureHowTasksAreRoutedToExecutionNodes", "Configure how tasks are routed to execution nodes.")}</p>
|
||||
<p className="settings-node-routing-note">{t("settings.nodeRouting.theseSettingsApplyAtTheProjectLevel", "These settings apply at the project level.")}</p>
|
||||
{/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
This row stays hand-rolled: routing safety requires the live NodeHealthDot for the selected node to sit between the control and its help text, and the shared select row renders only label/control/help with no slot for an adjacent status widget. Forcing it onto the primitive would move or drop the health readout, so the dot wins over row uniformity here.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultNodeId">{t("settings.nodeRouting.defaultExecutionNode", "Default Execution Node")}</label>
|
||||
<select id="defaultNodeId" className="select" value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""} onChange={(e) => {
|
||||
@@ -47,17 +53,23 @@ export function NodeRoutingSection({ scopeBanner, form, setForm, nodes }: NodeRo
|
||||
})()}
|
||||
<small>{t("settings.nodeRouting.usedWhenATaskHasNoNodeOverride", "Used when a task has no node override. Node status is shown for safer routing selection. No default \u2014 unset (local execution).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="unavailableNodePolicy">{t("settings.nodeRouting.unavailableNodePolicy", "Unavailable Node Policy")}</label>
|
||||
<select id="unavailableNodePolicy" className="select" value={form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"} onChange={(e) => setForm((f) => ({
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "unavailableNodePolicy",
|
||||
label: t("settings.nodeRouting.unavailableNodePolicy", "Unavailable Node Policy"),
|
||||
help: t("settings.nodeRouting.unavailableNodePolicyHint", "Default: block execution."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "block", label: t("settings.nodeRouting.blockExecution", "Block execution") },
|
||||
{ value: "fallback-local", label: t("settings.nodeRouting.fallBackToLocal", "Fall back to local") },
|
||||
],
|
||||
}}
|
||||
value={form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
|
||||
} as SettingsFormState))}>
|
||||
<option value="block">{t("settings.nodeRouting.blockExecution", "Block execution")}</option>
|
||||
<option value="fallback-local">{t("settings.nodeRouting.fallBackToLocal", "Fall back to local")}</option>
|
||||
</select>
|
||||
<small>{t("settings.nodeRouting.unavailableNodePolicyHint", "Default: block execution.")}</small>
|
||||
</div>
|
||||
unavailableNodePolicy: v as "block" | "fallback-local",
|
||||
} as SettingsFormState))}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
export default NodeRoutingSection;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Search entries for the Node Sync section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim — search matches the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The three gated rows are indexed even though they only render while `settingsSyncEnabled` is on: search lands the operator on the section, and hiding a setting from search because it is currently gated would make it undiscoverable exactly when someone is trying to find out how to turn it on.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const nodeSyncSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "node-sync",
|
||||
key: "settingsSyncEnabled",
|
||||
labelKey: "settings.nodeSync.enableAutomaticSettingsSync",
|
||||
labelFallback: " Enable automatic settings sync ",
|
||||
helpKey: "settings.nodeSync.automaticallySynchronizeSettingsBetweenThisNodeAndConnected",
|
||||
helpFallback:
|
||||
"Automatically synchronize settings between this node and connected remote nodes. Default: disabled.",
|
||||
},
|
||||
{
|
||||
sectionId: "node-sync",
|
||||
key: "settingsSyncAuth",
|
||||
labelKey: "settings.nodeSync.syncModelAuthCredentials",
|
||||
labelFallback: " Sync model auth credentials ",
|
||||
helpKey: "settings.nodeSync.includeAPIKeysAndOAuthTokensInSync",
|
||||
helpFallback: "Include API keys and OAuth tokens in sync operations. Default: disabled.",
|
||||
keywords: ["secrets", "authentication"],
|
||||
},
|
||||
{
|
||||
sectionId: "node-sync",
|
||||
key: "settingsSyncInterval",
|
||||
labelKey: "settings.nodeSync.syncInterval",
|
||||
labelFallback: "Sync interval",
|
||||
helpKey: "settings.nodeSync.syncIntervalHint",
|
||||
helpFallback: "Default: every 15 minutes.",
|
||||
keywords: ["frequency", "how often", "schedule"],
|
||||
},
|
||||
{
|
||||
sectionId: "node-sync",
|
||||
key: "settingsSyncConflictResolution",
|
||||
labelKey: "settings.nodeSync.conflictResolution",
|
||||
labelFallback: "Conflict resolution",
|
||||
helpKey: "settings.nodeSync.conflictResolutionHint",
|
||||
helpFallback: "Default: last write wins.",
|
||||
},
|
||||
];
|
||||
@@ -1,48 +1,79 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
export interface NodeSyncSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
}
|
||||
export function NodeSyncSection({ scopeBanner, form, setForm }: NodeSyncSectionProps) {
|
||||
export type NodeSyncSectionProps = SectionBaseProps;
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Every sync setting here is global (DEFAULT_GLOBAL_SETTINGS): sync is a property of this node's relationship to its peers, not of any one project, so the badges read "global" even though the operator reached them from a project.
|
||||
|
||||
FNXC:NodeSync 2026-07-15-17:35:
|
||||
The auth/interval/conflict rows stay gated behind settingsSyncEnabled. Credential sync in particular must not be reachable — even to read — while sync is off, so the gate is conditional rendering rather than a disabled row.
|
||||
*/
|
||||
export function NodeSyncSection({ form, setForm }: NodeSyncSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.nodeSync.nodeSync", "Node Sync")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncEnabled" className="checkbox-label">
|
||||
<input id="settingsSyncEnabled" type="checkbox" checked={form.settingsSyncEnabled || false} onChange={(e) => setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))}/>{t("settings.nodeSync.enableAutomaticSettingsSync", " Enable automatic settings sync ")}</label>
|
||||
<small>{t("settings.nodeSync.automaticallySynchronizeSettingsBetweenThisNodeAndConnected", "Automatically synchronize settings between this node and connected remote nodes. Default: disabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "settingsSyncEnabled",
|
||||
label: t("settings.nodeSync.enableAutomaticSettingsSync", " Enable automatic settings sync "),
|
||||
help: t("settings.nodeSync.automaticallySynchronizeSettingsBetweenThisNodeAndConnected", "Automatically synchronize settings between this node and connected remote nodes. Default: disabled."),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.settingsSyncEnabled || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, settingsSyncEnabled: v === true }))}
|
||||
/>
|
||||
{form.settingsSyncEnabled && (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncAuth" className="checkbox-label">
|
||||
<input id="settingsSyncAuth" type="checkbox" checked={form.settingsSyncAuth || false} onChange={(e) => setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))}/>{t("settings.nodeSync.syncModelAuthCredentials", " Sync model auth credentials ")}</label>
|
||||
<small>{t("settings.nodeSync.includeAPIKeysAndOAuthTokensInSync", "Include API keys and OAuth tokens in sync operations. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncInterval">{t("settings.nodeSync.syncInterval", "Sync interval")}</label>
|
||||
<select id="settingsSyncInterval" className="select" value={form.settingsSyncInterval || 900000} onChange={(e) => setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))}>
|
||||
<option value={300000}>{t("settings.nodeSync.every5Minutes", "Every 5 minutes")}</option>
|
||||
<option value={900000}>{t("settings.nodeSync.every15Minutes", "Every 15 minutes")}</option>
|
||||
<option value={1800000}>{t("settings.nodeSync.every30Minutes", "Every 30 minutes")}</option>
|
||||
<option value={3600000}>{t("settings.nodeSync.every1Hour", "Every 1 hour")}</option>
|
||||
</select>
|
||||
<small>{t("settings.nodeSync.syncIntervalHint", "Default: every 15 minutes.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settingsSyncConflictResolution">{t("settings.nodeSync.conflictResolution", "Conflict resolution")}</label>
|
||||
<select id="settingsSyncConflictResolution" className="select" value={form.settingsSyncConflictResolution || "last-write-wins"} onChange={(e) => setForm((f) => ({
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "settingsSyncAuth",
|
||||
label: t("settings.nodeSync.syncModelAuthCredentials", " Sync model auth credentials "),
|
||||
help: t("settings.nodeSync.includeAPIKeysAndOAuthTokensInSync", "Include API keys and OAuth tokens in sync operations. Default: disabled."),
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.settingsSyncAuth || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, settingsSyncAuth: v === true }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:NodeSync 2026-07-15-17:35:
|
||||
The interval is stored in milliseconds but offered as a fixed set of periods, so the select carries stringified ms values and parses back on change — operators pick "Every 15 minutes", the engine reads 900000.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "settingsSyncInterval",
|
||||
label: t("settings.nodeSync.syncInterval", "Sync interval"),
|
||||
help: t("settings.nodeSync.syncIntervalHint", "Default: every 15 minutes."),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "300000", label: t("settings.nodeSync.every5Minutes", "Every 5 minutes") },
|
||||
{ value: "900000", label: t("settings.nodeSync.every15Minutes", "Every 15 minutes") },
|
||||
{ value: "1800000", label: t("settings.nodeSync.every30Minutes", "Every 30 minutes") },
|
||||
{ value: "3600000", label: t("settings.nodeSync.every1Hour", "Every 1 hour") },
|
||||
],
|
||||
}}
|
||||
value={String(form.settingsSyncInterval || 900000)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, settingsSyncInterval: parseInt(v ?? "", 10) }))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "settingsSyncConflictResolution",
|
||||
label: t("settings.nodeSync.conflictResolution", "Conflict resolution"),
|
||||
help: t("settings.nodeSync.conflictResolutionHint", "Default: last write wins."),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "last-write-wins", label: t("settings.nodeSync.lastWriteWins", "Last write wins") },
|
||||
{ value: "always-ask", label: t("settings.nodeSync.alwaysAsk", "Always ask") },
|
||||
{ value: "keep-local", label: t("settings.nodeSync.keepLocal", "Keep local") },
|
||||
{ value: "keep-remote", label: t("settings.nodeSync.keepRemote", "Keep remote") },
|
||||
],
|
||||
}}
|
||||
value={form.settingsSyncConflictResolution || "last-write-wins"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
settingsSyncConflictResolution: e.target.value as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote",
|
||||
}))}>
|
||||
<option value="last-write-wins">{t("settings.nodeSync.lastWriteWins", "Last write wins")}</option>
|
||||
<option value="always-ask">{t("settings.nodeSync.alwaysAsk", "Always ask")}</option>
|
||||
<option value="keep-local">{t("settings.nodeSync.keepLocal", "Keep local")}</option>
|
||||
<option value="keep-remote">{t("settings.nodeSync.keepRemote", "Keep remote")}</option>
|
||||
</select>
|
||||
<small>{t("settings.nodeSync.conflictResolutionHint", "Default: last write wins.")}</small>
|
||||
</div>
|
||||
settingsSyncConflictResolution: v as "last-write-wins" | "always-ask" | "keep-local" | "keep-remote",
|
||||
}))}
|
||||
/>
|
||||
</>)}
|
||||
{/* KTD-8: workflow settings are not yet part of the cross-node sync
|
||||
channel. Non-dismissible, informational only, no action affordance. */}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Search entries for the Notifications section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's still-bespoke controls are deliberately absent — they are not descriptor rows, so indexing them would point search at an anchor that does not exist: the ntfy/webhook Enable switches (card headers), the ntfy topic + Advanced disclosure (server URL, access token), the per-event checkbox grids, and the Test notification buttons.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const notificationsSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "failureNotificationMode",
|
||||
labelKey: "settings.notifications.failureNotificationMode",
|
||||
labelFallback: "Failure notification mode",
|
||||
helpKey: "settings.notifications.stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits",
|
||||
helpFallback:
|
||||
"Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"retry" and "noise" are indexed because this control is what an operator reaches for when auto-retried failures are spamming them — vocabulary the copy never uses, since it describes the modes rather than the problem they solve.
|
||||
*/
|
||||
keywords: ["retry", "noise", "alerts"],
|
||||
},
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "failureNotificationDelayMs",
|
||||
labelKey: "settings.notifications.failureNotificationDelayMs",
|
||||
labelFallback: "Failure notification delay (ms)",
|
||||
helpKey: "settings.notifications.howLongAFailureMustPersistBeforeA",
|
||||
helpFallback:
|
||||
" How long a failure must persist before a push notification is sent. 0 = notify immediately. Default: 30000 (30 seconds). ",
|
||||
keywords: ["debounce", "wait", "throttle"],
|
||||
},
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "ntfyDashboardHost",
|
||||
labelKey: "settings.notifications.dashboardHostname",
|
||||
labelFallback: "Dashboard Hostname",
|
||||
helpKey: "settings.notifications.baseURLForDeepLinksInNotificationsWhen",
|
||||
helpFallback:
|
||||
" Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. No default — unset. ",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"ntfy" is a keyword rather than left to the copy: the stored key is `ntfyDashboardHost` and the row renders inside the ntfy provider card, but its label and help never say "ntfy", so an operator scanning for their ntfy configuration would otherwise miss it.
|
||||
*/
|
||||
keywords: ["ntfy", "deep link", "base url"],
|
||||
},
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "webhookUrl",
|
||||
labelKey: "settings.notifications.webhookURL",
|
||||
labelFallback: "Webhook URL",
|
||||
helpKey: "settings.notifications.webhookUrlHint",
|
||||
helpFallback: "No default — unset.",
|
||||
keywords: ["endpoint", "hook", "callback"],
|
||||
},
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "webhookFormat",
|
||||
labelKey: "settings.notifications.format",
|
||||
labelFallback: "Format",
|
||||
helpKey: "settings.notifications.webhookFormatHint",
|
||||
helpFallback: "Default: generic.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The destinations this setting selects between live in the option labels, which the index does not read — only the row's label and help. "Slack"/"Discord" are the words an operator actually searches, and the label is the single word "Format", so without these keywords the row is effectively unfindable.
|
||||
*/
|
||||
keywords: ["slack", "discord", "payload", "webhook"],
|
||||
},
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "ntfyBaseUrl",
|
||||
labelKey: "settings.notifications.customNtfyServerURLOptional",
|
||||
labelFallback: "Custom ntfy server URL (optional)",
|
||||
helpKey: "settings.notifications.leaveBlankToKeepTheDefaultServerHttps",
|
||||
helpFallback:
|
||||
" Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. No default — unset. ",
|
||||
keywords: ["self-hosted", "custom server"],
|
||||
},
|
||||
{
|
||||
sectionId: "notifications",
|
||||
key: "ntfyAccessToken",
|
||||
labelKey: "settings.notifications.accessTokenOptional",
|
||||
labelFallback: "Access token (optional)",
|
||||
helpKey: "settings.notifications.leaveBlankToPublishWithoutAuthenticationWhenSet",
|
||||
helpFallback:
|
||||
" Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. No default — unset. ",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-18:52:
|
||||
Only the label and help are indexed — never the stored value. The index is a module-scope constant of static copy, so a secret cannot reach it.
|
||||
*/
|
||||
keywords: ["auth", "bearer", "credential", "secret"],
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NtfyNotificationEvent } from "@fusion/core";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
/** Default event set used when a provider has no explicit `*Events` override. */
|
||||
export const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
|
||||
@@ -43,7 +46,6 @@ export const NOTIFICATION_EVENT_OPTIONS: Array<{
|
||||
];
|
||||
export type TestNotificationProvider = "ntfy" | "webhook" | "ntfy-message" | "ntfy-room";
|
||||
export interface NotificationsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
testNotificationLoading: Record<string, boolean>;
|
||||
testNotificationResult: Record<string, {
|
||||
status: "success" | "error";
|
||||
@@ -51,10 +53,23 @@ export interface NotificationsSectionProps extends SectionBaseProps {
|
||||
}>;
|
||||
onTestProviderNotification: (provider: TestNotificationProvider) => void;
|
||||
}
|
||||
export function NotificationsSection({ scopeBanner, form, setForm, testNotificationLoading, testNotificationResult, onTestProviderNotification, }: NotificationsSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Plain configuration rows render through the shared settings primitives; the hand-rolled `form-group` wrappers they sat in are dropped rather than kept around them, because `.form-group label` (and the `.settings-content` narrowing of it) out-specifies `.settings-field-row-label` and would re-impose the uppercase/muted treatment the primitives exist to retire. `.form-group` itself stays global and untouched — 35 non-settings files style forms with it.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Every migrated row here is global: ntfy, webhook, and failure-notification keys all live in DEFAULT_GLOBAL_SETTINGS, so notification delivery is configured once per operator rather than per project.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Deliberately NOT migrated, and why — each is a real behavior/copy constraint, not an oversight:
|
||||
- The ntfy/webhook `Enable` checkboxes stay in `.notification-provider-header`, a flex space-between card header pairing a provider title with its switch. That is a card header, not a plain label→control row.
|
||||
- `ntfyTopic`'s help embeds an ntfy.sh anchor, and the primitives take `help` as a pre-translated string; migrating would silently drop the link.
|
||||
- (Resolved 2026-07-15-18:52) `ntfyBaseUrl`/`ntfyAccessToken` previously could not migrate because SettingsTextRow hardcoded type="text" and would have rendered the access token UNMASKED. The descriptor now carries `type`, so both are migrated inside the Advanced disclosure, with the token masked and `autocomplete="off"` by default.
|
||||
- The `ntfyEvents` / `webhookEvents` grids are per-event checkbox lists with their own descriptions, not single controls.
|
||||
*/
|
||||
export function NotificationsSection({ form, setForm, testNotificationLoading, testNotificationResult, onTestProviderNotification, }: NotificationsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.notifications.notifications", "Notifications")}</h4>
|
||||
|
||||
<div className="notification-provider-card">
|
||||
@@ -63,29 +78,42 @@ export function NotificationsSection({ scopeBanner, form, setForm, testNotificat
|
||||
The failure-notification card must reuse `.notification-provider-body` because `.notification-provider-card` has no own padding; this keeps its field gutters aligned with ntfy/webhook provider cards on desktop and mobile.
|
||||
*/}
|
||||
<div className="notification-provider-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="failureNotificationMode">{t("settings.notifications.failureNotificationMode", "Failure notification mode")}</label>
|
||||
<select id="failureNotificationMode" value={form.failureNotificationMode ?? "sticky-only"} onChange={(e) => {
|
||||
const value = e.target.value as "sticky-only" | "all" | "terminal-only";
|
||||
setForm((f) => ({ ...f, failureNotificationMode: value }));
|
||||
}}>
|
||||
<option value="sticky-only">{t("settings.notifications.stickyFailuresOnlyDefault", "Sticky failures only (default)")}</option>
|
||||
<option value="terminal-only">{t("settings.notifications.terminalFailuresOnlySuppressAutoRetried", "Terminal failures only (suppress auto-retried)")}</option>
|
||||
<option value="all">{t("settings.notifications.allFailuresLegacy", "All failures (legacy)")}</option>
|
||||
</select>
|
||||
<small>{t("settings.notifications.stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits", "Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="failureNotificationDelayMs">{t("settings.notifications.failureNotificationDelayMs", "Failure notification delay (ms)")}</label>
|
||||
<input id="failureNotificationDelayMs" type="number" min={0} step={1000} disabled={(form.failureNotificationMode ?? "sticky-only") === "all"} value={form.failureNotificationDelayMs ?? 30000} onChange={(e) => {
|
||||
const parsed = Number(e.target.value);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0,
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.notifications.howLongAFailureMustPersistBeforeA", " How long a failure must persist before a push notification is sent. 0 = notify immediately. Default: 30000 (30 seconds). ")}</small>
|
||||
</div>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "failureNotificationMode",
|
||||
label: t("settings.notifications.failureNotificationMode", "Failure notification mode"),
|
||||
help: t("settings.notifications.stickyOnlySuppressesRecoveredFailuresTerminalOnlyWaits", "Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts."),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "sticky-only", label: t("settings.notifications.stickyFailuresOnlyDefault", "Sticky failures only (default)") },
|
||||
{ value: "terminal-only", label: t("settings.notifications.terminalFailuresOnlySuppressAutoRetried", "Terminal failures only (suppress auto-retried)") },
|
||||
{ value: "all", label: t("settings.notifications.allFailuresLegacy", "All failures (legacy)") },
|
||||
],
|
||||
}}
|
||||
value={form.failureNotificationMode ?? "sticky-only"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, failureNotificationMode: (v ?? "sticky-only") as "sticky-only" | "all" | "terminal-only" }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:FailureNotifications 2026-07-15-17:35:
|
||||
The delay is disabled rather than hidden in "all" mode: legacy alerting fires immediately, so the value cannot apply — but an operator switching modes needs to see the delay that will take effect again.
|
||||
Coercion is preserved verbatim from the hand-rolled input: a cleared field and any negative value both settle to 0 (notify immediately), never to undefined, so the stored key always holds a usable delay.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "failureNotificationDelayMs",
|
||||
label: t("settings.notifications.failureNotificationDelayMs", "Failure notification delay (ms)"),
|
||||
help: t("settings.notifications.howLongAFailureMustPersistBeforeA", " How long a failure must persist before a push notification is sent. 0 = notify immediately. Default: 30000 (30 seconds). "),
|
||||
scope: "global",
|
||||
min: 0,
|
||||
step: 1000,
|
||||
disabled: (form.failureNotificationMode ?? "sticky-only") === "all",
|
||||
}}
|
||||
value={form.failureNotificationDelayMs ?? 30000}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
failureNotificationDelayMs: v !== null && Number.isFinite(v) && v >= 0 ? v : 0,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,37 +122,68 @@ export function NotificationsSection({ scopeBanner, form, setForm, testNotificat
|
||||
<strong>{t("settings.notifications.ntfy", "ntfy")}</strong>
|
||||
<label htmlFor="ntfyEnabled" className="checkbox-label">
|
||||
<input id="ntfyEnabled" type="checkbox" checked={form.ntfyEnabled || false} onChange={(e) => setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))}/>{t("settings.notifications.enable", " Enable ")}</label>
|
||||
<small>{t("settings.notifications.ntfyEnabledHint", "Default: disabled.")}</small>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The tip replaces the `<small>` in place rather than wrapping label+tip in a `.settings-field-label-row`: `.notification-provider-header` is ALREADY that line — a flex/align-center row — so it needs no second one, and re-parenting the label would change what its `space-between` distributes. The invariant that matters still holds: the trigger is a sibling of the `<label>`, never a button nested inside it.
|
||||
*/}
|
||||
<SettingsHelpTip settingKey="ntfyEnabled">{t("settings.notifications.ntfyEnabledHint", "Default: disabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
{form.ntfyEnabled && (<div className="notification-provider-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyTopic">{t("settings.notifications.ntfyTopic", "ntfy Topic")}</label>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
This row cannot move onto the shared primitive (its `help` takes a pre-translated STRING, and this copy carries an ntfy.sh anchor a string would silently drop \u2014 see the note above), but it can still use the same affordance: SettingsHelpTip takes ReactNode, so the link and the `t()` fragments go behind the "?" verbatim.
|
||||
*/}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="ntfyTopic">{t("settings.notifications.ntfyTopic", "ntfy Topic")}</label>
|
||||
<SettingsHelpTip settingKey="ntfyTopic">{t("settings.notifications.yourNtfyShTopicName164Alphanumeric", " Your ntfy.sh topic name (1\u201364 alphanumeric/hyphen/underscore characters). No default \u2014 unset.")}{" "}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="settings-inline-link">{t("settings.notifications.learnMoreAboutNtfySh", " Learn more about ntfy.sh ")}</a>
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="ntfyTopic" type="text" placeholder={t("settings.notifications.myTopicName", "my-topic-name")} value={form.ntfyTopic || ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.notifications.yourNtfyShTopicName164Alphanumeric", " Your ntfy.sh topic name (1\u201364 alphanumeric/hyphen/underscore characters). No default \u2014 unset.")}{" "}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="settings-inline-link">{t("settings.notifications.learnMoreAboutNtfySh", " Learn more about ntfy.sh ")}</a>
|
||||
</small>
|
||||
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (<small className="field-error">{t("settings.notifications.topicMustBe164AlphanumericHyphenOr", " Topic must be 1\u201364 alphanumeric, hyphen, or underscore characters ")}</small>)}
|
||||
<details className="ntfy-advanced-disclosure">
|
||||
<summary>{t("settings.notifications.advanced", "Advanced")}</summary>
|
||||
{/*
|
||||
FNXC:SettingsSecurity 2026-07-15-18:52:
|
||||
The access token renders through `type: "password"` so it stays masked, and inherits the primitive's `autocomplete="off"` default so a browser never offers to save it \u2014 the same guarantees the hand-rolled input carried before it moved onto the shared row.
|
||||
The disclosure stays: these are the rarely-touched ntfy overrides, and a descriptor's help renders unconditionally, so flattening them into the section body would push the common case (topic) below a wall of prose.
|
||||
*/}
|
||||
<div className="ntfy-advanced-content">
|
||||
<label htmlFor="ntfyBaseUrl">{t("settings.notifications.customNtfyServerURLOptional", "Custom ntfy server URL (optional)")}</label>
|
||||
<input id="ntfyBaseUrl" type="url" placeholder={t("settings.notifications.httpsNtfySh", "https://ntfy.sh")} value={form.ntfyBaseUrl || ""} onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.notifications.leaveBlankToKeepTheDefaultServerHttps", " Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. No default \u2014 unset. ")}</small>
|
||||
<label htmlFor="ntfyAccessToken">{t("settings.notifications.accessTokenOptional", "Access token (optional)")}</label>
|
||||
<input id="ntfyAccessToken" type="password" autoComplete="off" placeholder={t("settings.notifications.tk", "tk_...")} value={form.ntfyAccessToken || ""} onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.notifications.leaveBlankToPublishWithoutAuthenticationWhenSet", " Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. No default \u2014 unset. ")}</small>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "ntfyBaseUrl",
|
||||
label: t("settings.notifications.customNtfyServerURLOptional", "Custom ntfy server URL (optional)"),
|
||||
help: t("settings.notifications.leaveBlankToKeepTheDefaultServerHttps", " Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. No default \u2014 unset. "),
|
||||
placeholder: t("settings.notifications.httpsNtfySh", "https://ntfy.sh"),
|
||||
type: "url",
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.ntfyBaseUrl ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ntfyBaseUrl: v || undefined }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "ntfyAccessToken",
|
||||
label: t("settings.notifications.accessTokenOptional", "Access token (optional)"),
|
||||
help: t("settings.notifications.leaveBlankToPublishWithoutAuthenticationWhenSet", " Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. No default \u2014 unset. "),
|
||||
placeholder: t("settings.notifications.tk", "tk_..."),
|
||||
type: "password",
|
||||
scope: "global",
|
||||
}}
|
||||
value={form.ntfyAccessToken ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ntfyAccessToken: v || undefined }))}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The per-event `<small>`s stay inline. They are OPTION descriptions inside one multi-select control — the thing an operator compares while ticking boxes — not help for 14 separate settings. Putting a "?" on every option would replace one scannable list with fourteen closed bubbles, which is the opposite of what moving help off the row is for.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label>{t("settings.notifications.notifyOnEvents", "Notify on events")}</label>
|
||||
<div className="ntfy-events-list">
|
||||
@@ -146,15 +205,22 @@ export function NotificationsSection({ scopeBanner, form, setForm, testNotificat
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyDashboardHost">{t("settings.notifications.dashboardHostname", "Dashboard Hostname")}</label>
|
||||
<input id="ntfyDashboardHost" type="text" placeholder={t("settings.notifications.httpLocalhost3000", "http://localhost:3000")} value={form.ntfyDashboardHost || ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.notifications.baseURLForDeepLinksInNotificationsWhen", " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. No default \u2014 unset. ")}</small>
|
||||
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (<small className="field-error">{t("settings.notifications.mustBeAValidURLStartingWithHttp", " Must be a valid URL starting with http:// or https:// ")}</small>)}
|
||||
</div>
|
||||
{/* FNXC:SettingsValidation 2026-07-15-17:35: The http(s) check rides the row's error band so an invalid deep-link host reports against the control that owns it. Empty coerces to undefined, not "", keeping "no default \u2014 unset" a real unset rather than a stored blank. */}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "ntfyDashboardHost",
|
||||
label: t("settings.notifications.dashboardHostname", "Dashboard Hostname"),
|
||||
help: t("settings.notifications.baseURLForDeepLinksInNotificationsWhen", " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. No default \u2014 unset. "),
|
||||
scope: "global",
|
||||
placeholder: t("settings.notifications.httpLocalhost3000", "http://localhost:3000"),
|
||||
}}
|
||||
value={form.ntfyDashboardHost || ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ntfyDashboardHost: v || undefined }))}
|
||||
error={form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost)
|
||||
? t("settings.notifications.mustBeAValidURLStartingWithHttp", " Must be a valid URL starting with http:// or https:// ")
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
<div className="notification-provider-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => onTestProviderNotification("ntfy")} disabled={testNotificationLoading["ntfy"] ||
|
||||
testNotificationLoading["ntfy-message"] ||
|
||||
@@ -197,29 +263,36 @@ export function NotificationsSection({ scopeBanner, form, setForm, testNotificat
|
||||
<strong>{t("settings.notifications.webhook", "Webhook")}</strong>
|
||||
<label htmlFor="webhookEnabled" className="checkbox-label">
|
||||
<input id="webhookEnabled" type="checkbox" checked={form.webhookEnabled || false} onChange={(e) => setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))}/>{t("settings.notifications.webhookNotifications", " Webhook notifications ")}</label>
|
||||
<small>{t("settings.notifications.webhookEnabledHint", "Default: disabled.")}</small>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-21:40: In-place tip for the same reason as the ntfy card header above. */}
|
||||
<SettingsHelpTip settingKey="webhookEnabled">{t("settings.notifications.webhookEnabledHint", "Default: disabled.")}</SettingsHelpTip>
|
||||
</div>
|
||||
{form.webhookEnabled && (<div className="notification-provider-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="webhookUrl">{t("settings.notifications.webhookURL", "Webhook URL")}</label>
|
||||
<input id="webhookUrl" type="text" placeholder={t("settings.notifications.httpsHooksExampleCom", "https://hooks.example.com/...")} value={form.webhookUrl || ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.notifications.webhookUrlHint", "No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="webhookFormat">{t("settings.notifications.format", "Format")}</label>
|
||||
<select id="webhookFormat" value={form.webhookFormat || "generic"} onChange={(e) => {
|
||||
const val = e.target.value as "slack" | "discord" | "generic";
|
||||
setForm((f) => ({ ...f, webhookFormat: val }));
|
||||
}}>
|
||||
<option value="slack">{t("settings.notifications.slack", "Slack")}</option>
|
||||
<option value="discord">{t("settings.notifications.discord", "Discord")}</option>
|
||||
<option value="generic">{t("settings.notifications.generic", "Generic")}</option>
|
||||
</select>
|
||||
<small>{t("settings.notifications.webhookFormatHint", "Default: generic.")}</small>
|
||||
</div>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "webhookUrl",
|
||||
label: t("settings.notifications.webhookURL", "Webhook URL"),
|
||||
help: t("settings.notifications.webhookUrlHint", "No default \u2014 unset."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.notifications.httpsHooksExampleCom", "https://hooks.example.com/..."),
|
||||
}}
|
||||
value={form.webhookUrl || ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, webhookUrl: v || undefined }))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "webhookFormat",
|
||||
label: t("settings.notifications.format", "Format"),
|
||||
help: t("settings.notifications.webhookFormatHint", "Default: generic."),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "slack", label: t("settings.notifications.slack", "Slack") },
|
||||
{ value: "discord", label: t("settings.notifications.discord", "Discord") },
|
||||
{ value: "generic", label: t("settings.notifications.generic", "Generic") },
|
||||
],
|
||||
}}
|
||||
value={form.webhookFormat || "generic"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, webhookFormat: (v ?? "generic") as "slack" | "discord" | "generic" }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t("settings.notifications.notifyOnEvents", "Notify on events")}</label>
|
||||
<div className="ntfy-events-list">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { PluginSlot } from "../../PluginSlot";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,16 +6,14 @@ const PluginManager = lazy(() => import("../../PluginManager").then((m) => ({ de
|
||||
const PiExtensionsManager = lazy(() => import("../../PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||
export type PluginsSubsectionId = "fusion-plugins" | "pi-extensions";
|
||||
export interface PluginsSectionProps {
|
||||
scopeBanner: ReactNode;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
activePluginsSubsection: PluginsSubsectionId;
|
||||
setActivePluginsSubsection: (id: PluginsSubsectionId) => void;
|
||||
}
|
||||
export function PluginsSection({ scopeBanner, projectId, addToast, activePluginsSubsection, setActivePluginsSubsection, }: PluginsSectionProps) {
|
||||
export function PluginsSection({ projectId, addToast, activePluginsSubsection, setActivePluginsSubsection, }: PluginsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.plugins.plugins", "Plugins")}</h4>
|
||||
<div className="settings-plugins-subsection-toggle" role="tablist" aria-label={t("settings.plugins.pluginManagerType", "Plugin manager type")}>
|
||||
<button type="button" id="plugins-tab-fusion-plugins" role="tab" aria-controls="plugins-panel-fusion-plugins" aria-selected={activePluginsSubsection === "fusion-plugins"} tabIndex={activePluginsSubsection === "fusion-plugins" ? 0 : -1} className={`settings-plugins-subsection-btn${activePluginsSubsection === "fusion-plugins" ? " active" : ""}`} onClick={() => setActivePluginsSubsection("fusion-plugins")}>{t("settings.plugins.fusionPlugins", " Fusion Plugins ")}</button>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GlobalSettings, Settings } from "@fusion/core";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { McpServersCard } from "./McpServersCard";
|
||||
|
||||
export interface ProjectMcpSectionProps {
|
||||
scopeBanner: ReactNode;
|
||||
form: Settings;
|
||||
setForm: Dispatch<SetStateAction<Settings>>;
|
||||
globalSettings?: Pick<GlobalSettings, "mcpServers"> | null;
|
||||
@@ -13,11 +12,10 @@ export interface ProjectMcpSectionProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function ProjectMcpSection({ scopeBanner, form, setForm, globalSettings, projectId, addToast }: ProjectMcpSectionProps) {
|
||||
export function ProjectMcpSection({ form, setForm, globalSettings, projectId, addToast }: ProjectMcpSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.nav.mcp", "MCP Servers")}</h4>
|
||||
<McpServersCard scope="project" form={form} setForm={setForm} globalSettings={globalSettings} projectId={projectId} addToast={addToast} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Search entries for the Project Models section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* This section is the reason the index was rewritten. Operators searched "summarize" and Project Models did not surface, because the old index was a hand-written keyword list per nav entry; it was patched twice (FN-7907, then again 2026-07-14) and still only covered whatever someone had thought to type. `autoSummarizeTitles` now carries NO keywords on purpose — "Auto-summarize" is in its own label and "summarization" in its help, and both are indexed automatically. Adding "summarize" back as a keyword would restate the label and re-create the list that rotted.
|
||||
* Absent by design: the model-lane pickers, the Chat default target picker, preset CRUD and its per-size grid, and the workflow lane rows. They are bespoke widgets, not descriptor rows — there is no `data-settings-key` anchor for a result to jump to.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const projectModelsSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "tokenCap",
|
||||
labelKey: "settings.projectModels.tokenCap",
|
||||
labelFallback: "Token Cap",
|
||||
helpKey: "settings.projectModels.automaticallyCompactContextWhenApproachingThisTokenCount",
|
||||
helpFallback:
|
||||
"Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. No default — unset (no cap).",
|
||||
keywords: ["context window", "limit", "budget"],
|
||||
},
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "chatNewSessionMode",
|
||||
labelKey: "settings.projectModels.chatNewSessionMode",
|
||||
labelFallback: "New Chat behavior",
|
||||
helpKey: "settings.projectModels.chatNewSessionModeHelp",
|
||||
helpFallback:
|
||||
"Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.",
|
||||
keywords: ["direct chat", "skip dialog"],
|
||||
},
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "autoSelectModelPreset",
|
||||
labelKey: "settings.projectModels.autoSelectPresetBasedOnTaskSize",
|
||||
labelFallback: " Auto-select preset based on task size ",
|
||||
helpKey: "settings.projectModels.autoSelectModelPresetHint",
|
||||
helpFallback: "Default: disabled.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The help is just "Default: disabled.", so this row's only indexable copy is its label. The sizes it selects between (S/M/L) live in the grid below, which is bespoke and unindexed, hence the size keywords here.
|
||||
*/
|
||||
keywords: ["small", "medium", "large", "S M L"],
|
||||
},
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "autoSummarizeTitles",
|
||||
labelKey: "settings.projectModels.autoSummarizeLongDescriptionsAsTitles",
|
||||
labelFallback: " Auto-summarize long descriptions as titles ",
|
||||
helpKey: "settings.projectModels.whenEnabledTasksCreatedWithoutATitleBut",
|
||||
helpFallback:
|
||||
" When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet. Default: disabled. ",
|
||||
},
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "useAiMergeCommitSummary",
|
||||
labelKey: "settings.projectModels.aIMergeCommitSummaries",
|
||||
labelFallback: " AI merge commit summaries ",
|
||||
helpKey: "settings.projectModels.whenEnabledMergeCommitMessagesIncludeAnAI",
|
||||
helpFallback:
|
||||
" When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model. Default: enabled. ",
|
||||
},
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "prTitlePromptInstructions",
|
||||
labelKey: "settings.projectModels.prTitlePromptInstructions",
|
||||
labelFallback: "PR title prompt guidance",
|
||||
helpKey: "settings.projectModels.prTitlePromptInstructionsHelp",
|
||||
helpFallback:
|
||||
"Guides the AI-generated Create PR title. Leave blank to use the default PR metadata prompt. No default — unset.",
|
||||
keywords: ["pull request", "conventional commit"],
|
||||
},
|
||||
{
|
||||
sectionId: "project-models",
|
||||
key: "prDescriptionPromptInstructions",
|
||||
labelKey: "settings.projectModels.prDescriptionPromptInstructions",
|
||||
labelFallback: "PR description prompt guidance",
|
||||
helpKey: "settings.projectModels.prDescriptionPromptInstructionsHelp",
|
||||
helpFallback:
|
||||
"Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt. No default — unset.",
|
||||
keywords: ["pull request", "body"],
|
||||
},
|
||||
];
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ModelPreset, Settings } from "@fusion/core";
|
||||
import { ApiRequestError, fetchWorkflow, fetchWorkflowSettingValues, updateWorkflowSettingValues, type ModelInfo, type WorkflowSettingDefinition, type WorkflowSettingRejection, type WorkflowSettingValuesPayload, } from "../../../api";
|
||||
import { CustomModelDropdown } from "../../CustomModelDropdown";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextareaRow } from "../SettingsTextareaRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import { applyPresetToSelection } from "../../../utils/modelPresets";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import type { ModelLane, SectionBaseProps, SectionSaveHandler, SettingsFormState } from "./context";
|
||||
@@ -127,14 +131,13 @@ export class WorkflowLaneFlushRejection extends Error {
|
||||
}
|
||||
}
|
||||
export interface ProjectModelsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
models: ProjectModelsSectionModelProps;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onOpenWorkflowSettings?: () => void;
|
||||
registerWorkflowLaneSaver?: (saver: SectionSaveHandler | null) => void;
|
||||
}
|
||||
export function ProjectModelsSection({ scopeBanner, form, setForm, models, projectId, onOpenWorkflowSettings, registerWorkflowLaneSaver, }: ProjectModelsSectionProps) {
|
||||
export function ProjectModelsSection({ form, setForm, models, projectId, onOpenWorkflowSettings, registerWorkflowLaneSaver, }: ProjectModelsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
|
||||
const { modelLanes, getLaneStatus, getLaneValue, updateLaneValue, resetLaneValue, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, availableModels, modelsLoading, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, editingPresetId, setEditingPresetId, presetDraft, setPresetDraft, onSavePresetDraft, confirmDelete, } = models;
|
||||
@@ -347,11 +350,19 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
const isOverridden = status === "overridden" || Boolean(thinkingValue);
|
||||
const laneLabel = getProjectLaneLabel(lane);
|
||||
return (<div className="form-group" key={lane.laneId}>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-23:10:
|
||||
Lane help rides the same "?" as every other row. It reads as an exception — a lane is a label + inherited/override badge + dropdown + conditional Reset, and its copy ends in the resolved fallback CHAIN — but that argues for WHERE the tip hangs (the label row, beside the badge), not for keeping a paragraph. Left inline, Project Models was the one section still showing prose under every control while its neighbours showed an icon; the global lanes next door already use the tip.
|
||||
The badge stays in view precisely because it IS live state ("Override (Project)" vs "Inherited (Global)") — the fallback chain behind it explains that badge, and is what an operator opens deliberately.
|
||||
*/}
|
||||
<div className="settings-model-lane-label-row">
|
||||
<label htmlFor={`${lane.laneId}Model`}>{laneLabel}</label>
|
||||
<span className={`settings-lane-badge ${isOverridden ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={isOverridden ? "Explicitly set for this project" : "Inherited from global settings"}>
|
||||
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
|
||||
</span>
|
||||
<SettingsHelpTip settingKey={`${lane.laneId}Model`}>
|
||||
{getProjectLaneHelperText(lane)}{t("settings.projectModels.fallsBackTo", " Falls back to: ")}{lane.fallbackOrder}.
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
@@ -359,9 +370,6 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
</div>
|
||||
{isOverridden && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={() => { resetLaneValue(lane); resetLaneThinkingValue(lane); }} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
<small>
|
||||
{getProjectLaneHelperText(lane)}{t("settings.projectModels.fallsBackTo", " Falls back to: ")}{lane.fallbackOrder}.
|
||||
</small>
|
||||
</div>);
|
||||
};
|
||||
const chatDefaultKind = form.chatDefaultKind ?? "model";
|
||||
@@ -395,21 +403,26 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
setForm((f) => ({ ...f, chatNewSessionMode: undefined, chatDefaultKind: undefined, chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState));
|
||||
};
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
|
||||
{/* --- Token Cap --- */}
|
||||
<h4 className="settings-section-heading">{t("settings.projectModels.tokenCap", "Token Cap")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tokenCap">{t("settings.projectModels.tokenCap", "Token Cap")}</label>
|
||||
<div className="settings-token-cap-row">
|
||||
<input id="tokenCap" type="number" placeholder={t("settings.projectModels.noCap", "No cap")} value={form.tokenCap ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState));
|
||||
}}/>
|
||||
{form.tokenCap != null && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToDefaultNoCap", "Reset to default (no cap)")} onClick={() => setForm((f) => ({ ...f, tokenCap: null } as unknown as SettingsFormState))} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
<small>{t("settings.projectModels.automaticallyCompactContextWhenApproachingThisTokenCount", "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. No default \u2014 unset (no cap).")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
The reset affordance stays conditional on an actual cap being set: "no cap" is the unset state, so offering to reset a lane that is already unset would advertise an action with nothing to undo.
|
||||
`v ? Math.trunc(v) : null` reproduces the previous `val ? parseInt(val, 10) : null` contract exactly \u2014 a token cap is a whole number of tokens, and 0 means "no cap" (null), not a cap of zero.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "tokenCap",
|
||||
label: t("settings.projectModels.tokenCap", "Token Cap"),
|
||||
help: t("settings.projectModels.automaticallyCompactContextWhenApproachingThisTokenCount", "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. No default \u2014 unset (no cap)."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.projectModels.noCap", "No cap"),
|
||||
}}
|
||||
value={form.tokenCap ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, tokenCap: v ? Math.trunc(v) : null } as SettingsFormState))}
|
||||
clearable={form.tokenCap != null}
|
||||
/>
|
||||
|
||||
{/* --- Project Model Lanes --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.modelLanes", "Model Lanes")}</h4>
|
||||
@@ -421,14 +434,25 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
{/* FNXC:ChatModels 2026-07-12-20:45: Project Models owns the Direct-chat default because New Chat needs a project-scoped model-or-agent target plus prompt-vs-direct creation mode without changing workflow or in-chat switcher settings. */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.chatHeading", "Chat")}</h4>
|
||||
<p className="settings-description">{t("settings.projectModels.chatDescription", "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.")}</p>
|
||||
<div className="form-group" data-testid="project-models-chat-mode">
|
||||
<label htmlFor="chatNewSessionMode">{t("settings.projectModels.chatNewSessionMode", "New Chat behavior")}</label>
|
||||
<select id="chatNewSessionMode" value={form.chatNewSessionMode ?? "prompt"} onChange={(event) => setForm((f) => ({ ...f, chatNewSessionMode: event.target.value === "always-default" ? "always-default" : undefined } as SettingsFormState))}>
|
||||
<option value="prompt">{t("settings.projectModels.chatNewSessionModePrompt", "Prompt for model each time")}</option>
|
||||
<option value="always-default">{t("settings.projectModels.chatNewSessionModeAlwaysDefault", "Always use configured default")}</option>
|
||||
</select>
|
||||
<small>{t("settings.projectModels.chatNewSessionModeHelp", "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
Only the mode select migrates to a primitive. The target picker below it (Model/Agent segmented toggle + model dropdown or agent select + shared Reset) is one compound control over five form keys, not a row per key, so it stays bespoke.
|
||||
"prompt" is the unset default rather than a stored value: anything that is not `always-default` writes `undefined` so the key is deleted rather than persisted at its default.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "chatNewSessionMode",
|
||||
label: t("settings.projectModels.chatNewSessionMode", "New Chat behavior"),
|
||||
help: t("settings.projectModels.chatNewSessionModeHelp", "Prompt mode opens New Chat with this default preselected. Always-default mode skips the dialog when the configured default is complete."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "prompt", label: t("settings.projectModels.chatNewSessionModePrompt", "Prompt for model each time") },
|
||||
{ value: "always-default", label: t("settings.projectModels.chatNewSessionModeAlwaysDefault", "Always use configured default") },
|
||||
],
|
||||
}}
|
||||
value={form.chatNewSessionMode ?? "prompt"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, chatNewSessionMode: v === "always-default" ? "always-default" : undefined } as SettingsFormState))}
|
||||
/>
|
||||
<div className="form-group" data-testid="project-models-chat-kind">
|
||||
<label>{t("settings.projectModels.chatDefaultKind", "Chat default target")}</label>
|
||||
<div className="chat-new-dialog-mode-toggle" data-testid="project-models-chat-kind-toggle">
|
||||
@@ -441,14 +465,21 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
</div>
|
||||
</div>
|
||||
{chatDefaultKind === "model" ? (<div className="form-group" data-testid="project-models-chat-model">
|
||||
<label htmlFor="chatDefaultModel">{t("settings.projectModels.chatDefaultModel", "Chat Default Model")}</label>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Model mode is a plain label + control + help row, so its help hangs off the same "?" as the New Chat behavior select directly above it instead of printing a paragraph beside it.
|
||||
The agent-mode branch below keeps its `<small>` inline: that slot swaps to "No agents are available for this project yet.", which explains an empty picker and must stay in view.
|
||||
*/}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="chatDefaultModel">{t("settings.projectModels.chatDefaultModel", "Chat Default Model")}</label>
|
||||
<SettingsHelpTip settingKey="chatDefaultModel">{t("settings.projectModels.chatDefaultModelHelp", "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
<CustomModelDropdown id="chatDefaultModel" label={t("settings.projectModels.chatDefaultModel", "Chat Default Model")} models={availableModels} value={chatDefaultModelValue} onChange={setChatDefaultModelValue} placeholder={t("settings.projectModels.selectChatDefaultModel", "Select a chat default model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={chatDefaultThinkingValue} onThinkingLevelChange={setChatDefaultThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
|
||||
</div>
|
||||
{chatDefaultCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.chatDefaultReset", "Reset Chat default")} onClick={resetChatDefaultValue}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
<small>{t("settings.projectModels.chatDefaultModelHelp", "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.")}</small>
|
||||
</div>) : (<div className="form-group" data-testid="project-models-chat-agent">
|
||||
<label htmlFor="chatDefaultAgentId">{t("settings.projectModels.chatDefaultAgent", "Chat Default Agent")}</label>
|
||||
<div className="settings-model-lane-control-row">
|
||||
@@ -489,6 +520,8 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
<span className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={customized ? "Explicitly set for this project workflow" : "Inherited from workflow defaults"}>
|
||||
{customized ? "Override (Project)" : "Inherited (Workflow)"}
|
||||
</span>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-23:10: Same affordance as the project lanes above — a workflow lane is the same shape, so its help hangs off the label row too rather than sitting under the dropdown as prose. */}
|
||||
{pair.help ? <SettingsHelpTip settingKey={`workflow-${pair.id}-model`}>{pair.help}</SettingsHelpTip> : null}
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
@@ -496,7 +529,6 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
</div>
|
||||
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
<small>{pair.help}</small>
|
||||
{error ? <small className="settings-error" data-testid={`workflow-model-lane-error-${pair.id}`}>{error}</small> : null}
|
||||
</div>);
|
||||
})}
|
||||
@@ -605,11 +637,16 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
</div>
|
||||
</div>) : null}
|
||||
|
||||
<div className="form-group settings-preset-auto-select">
|
||||
<label htmlFor="autoSelectModelPreset" className="checkbox-label">
|
||||
<input id="autoSelectModelPreset" type="checkbox" checked={form.autoSelectModelPreset || false} onChange={(e) => setForm((current) => ({ ...current, autoSelectModelPreset: e.target.checked }))}/>{t("settings.projectModels.autoSelectPresetBasedOnTaskSize", " Auto-select preset based on task size ")}</label>
|
||||
<small>{t("settings.projectModels.autoSelectModelPresetHint", "Default: disabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "autoSelectModelPreset",
|
||||
label: t("settings.projectModels.autoSelectPresetBasedOnTaskSize", " Auto-select preset based on task size "),
|
||||
help: t("settings.projectModels.autoSelectModelPresetHint", "Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.autoSelectModelPreset || false}
|
||||
onChange={(v) => setForm((current) => ({ ...current, autoSelectModelPreset: v === true }))}
|
||||
/>
|
||||
|
||||
{form.autoSelectModelPreset ? (<div className="settings-preset-size-grid">
|
||||
{(["S", "M", "L"] as const).map((sizeKey) => (<div className="form-group settings-preset-size-row" key={sizeKey}>
|
||||
@@ -645,6 +682,10 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
<span className={`settings-lane-badge ${titleSummarizerFallbackCustomized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={titleSummarizerFallbackCustomized ? "Explicitly set for this project" : "Inherited from global settings"}>
|
||||
{titleSummarizerFallbackCustomized ? "Override (Project)" : "Inherited (Global)"}
|
||||
</span>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-23:10: Same lane shape as the rows above, so its help hangs off the label row too — this was the last row in Settings still rendering help as a paragraph. */}
|
||||
<SettingsHelpTip settingKey="titleSummarizerFallbackModel">
|
||||
{t("settings.projectModels.titleSummarizerFallbackHelp", "Fallback provider and model used when the primary Title Summarizer model cannot be used. Falls back to the global summarization lane and then the default model chain.")}
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
@@ -652,36 +693,61 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
|
||||
</div>
|
||||
{titleSummarizerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetTitleSummarizerFallbackValue} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
<small>{t("settings.projectModels.titleSummarizerFallbackHelp", "Fallback provider and model used when the primary Title Summarizer model cannot be used. Falls back to the global summarization lane and then the default model chain.")}</small>
|
||||
</div>
|
||||
</>)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoSummarizeTitles" className="checkbox-label">
|
||||
<input id="autoSummarizeTitles" type="checkbox" checked={form.autoSummarizeTitles || false} onChange={(e) => setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}/>{t("settings.projectModels.autoSummarizeLongDescriptionsAsTitles", " Auto-summarize long descriptions as titles ")}</label>
|
||||
<small>{t("settings.projectModels.whenEnabledTasksCreatedWithoutATitleBut", " When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet. Default: disabled. ")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
This is the row operators searched "summarize" for and could not find (FN-7907, patched again 2026-07-14). It is now indexed by descriptor key from ProjectModelsSection.search.ts, so the word in its own label is what search matches — no hand-maintained keyword list to fall behind again.
|
||||
*/}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "autoSummarizeTitles",
|
||||
label: t("settings.projectModels.autoSummarizeLongDescriptionsAsTitles", " Auto-summarize long descriptions as titles "),
|
||||
help: t("settings.projectModels.whenEnabledTasksCreatedWithoutATitleBut", " When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet. Default: disabled. "),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.autoSummarizeTitles || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, autoSummarizeTitles: v === true }))}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="useAiMergeCommitSummary" className="checkbox-label">
|
||||
<input id="useAiMergeCommitSummary" type="checkbox" checked={form.useAiMergeCommitSummary || false} onChange={(e) => setForm((f) => ({ ...f, useAiMergeCommitSummary: e.target.checked }))}/>{t("settings.projectModels.aIMergeCommitSummaries", " AI merge commit summaries ")}</label>
|
||||
<small>{t("settings.projectModels.whenEnabledMergeCommitMessagesIncludeAnAI", " When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model. Default: enabled. ")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "useAiMergeCommitSummary",
|
||||
label: t("settings.projectModels.aIMergeCommitSummaries", " AI merge commit summaries "),
|
||||
help: t("settings.projectModels.whenEnabledMergeCommitMessagesIncludeAnAI", " When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model. Default: enabled. "),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.useAiMergeCommitSummary || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, useAiMergeCommitSummary: v === true }))}
|
||||
/>
|
||||
|
||||
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (<p className="settings-description">
|
||||
{t("settings.movedStub.summarizerModelInline", "These summarization model controls govern title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.")}
|
||||
</p>)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="prTitlePromptInstructions">{t("settings.projectModels.prTitlePromptInstructions", "PR title prompt guidance")}</label>
|
||||
<textarea id="prTitlePromptInstructions" value={form.prTitlePromptInstructions || ""} onChange={(e) => setForm((f) => ({ ...f, prTitlePromptInstructions: e.target.value }))} rows={3} placeholder={t("settings.projectModels.prTitlePromptInstructionsPlaceholder", "Example: Use conventional-commit style and keep titles under 72 characters.")}/>
|
||||
<small>{t("settings.projectModels.prTitlePromptInstructionsHelp", "Guides the AI-generated Create PR title. Leave blank to use the default PR metadata prompt. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<SettingsTextareaRow
|
||||
descriptor={{
|
||||
key: "prTitlePromptInstructions",
|
||||
label: t("settings.projectModels.prTitlePromptInstructions", "PR title prompt guidance"),
|
||||
help: t("settings.projectModels.prTitlePromptInstructionsHelp", "Guides the AI-generated Create PR title. Leave blank to use the default PR metadata prompt. No default \u2014 unset."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.projectModels.prTitlePromptInstructionsPlaceholder", "Example: Use conventional-commit style and keep titles under 72 characters."),
|
||||
}}
|
||||
value={form.prTitlePromptInstructions || ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, prTitlePromptInstructions: v ?? "" }))}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="prDescriptionPromptInstructions">{t("settings.projectModels.prDescriptionPromptInstructions", "PR description prompt guidance")}</label>
|
||||
<textarea id="prDescriptionPromptInstructions" value={form.prDescriptionPromptInstructions || ""} onChange={(e) => setForm((f) => ({ ...f, prDescriptionPromptInstructions: e.target.value }))} rows={4} placeholder={t("settings.projectModels.prDescriptionPromptInstructionsPlaceholder", "Example: Emphasize operator-facing behavior and list verification commands exactly.")}/>
|
||||
<small>{t("settings.projectModels.prDescriptionPromptInstructionsHelp", "Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<SettingsTextareaRow
|
||||
descriptor={{
|
||||
key: "prDescriptionPromptInstructions",
|
||||
label: t("settings.projectModels.prDescriptionPromptInstructions", "PR description prompt guidance"),
|
||||
help: t("settings.projectModels.prDescriptionPromptInstructionsHelp", "Guides the AI-generated Create PR summary, changes, and testing sections. Leave blank to use the default PR metadata prompt. No default \u2014 unset."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.projectModels.prDescriptionPromptInstructionsPlaceholder", "Example: Emphasize operator-facing behavior and list verification commands exactly."),
|
||||
}}
|
||||
value={form.prDescriptionPromptInstructions || ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, prDescriptionPromptInstructions: v ?? "" }))}
|
||||
/>
|
||||
</section>
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* `agentPrompts`/`promptOverrides` off the modal form and relays edits back
|
||||
* through `setForm`; the shell keeps persistence + save-split.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentPromptsConfig } from "@fusion/core";
|
||||
import { AgentPromptsManager } from "../../AgentPromptsManager";
|
||||
@@ -13,7 +12,6 @@ import { MovedSettingsStub } from "./MovedSettingsStub";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
|
||||
export interface PromptsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
/**
|
||||
* FNXC:Settings 2026-06-26-16:54:
|
||||
* Settings Prompts and Workflow Editor prompts are distinct editing surfaces. Settings owns agent role templates plus PromptKey segment overrides, while this callback links users to per-workflow, per-node prompt/gate prompts in the Workflow Editor.
|
||||
@@ -21,11 +19,10 @@ export interface PromptsSectionProps extends SectionBaseProps {
|
||||
onOpenWorkflowSettings?: () => void;
|
||||
}
|
||||
|
||||
export function PromptsSection({ scopeBanner, form, setForm, onOpenWorkflowSettings }: PromptsSectionProps) {
|
||||
export function PromptsSection({ form, setForm, onOpenWorkflowSettings }: PromptsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.nav.prompts", "Prompts")}</h4>
|
||||
<div className="form-group">
|
||||
<small>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Search entries for the Remote Access section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's still-bespoke controls are deliberately absent — they are not descriptor rows, so indexing them would point search at an anchor that does not exist: the provider radio cards, the Quick/Named Tunnel disclosure and its tunnel name/token/ingress fields, the auth-link token-type select, and the Start/Stop/Regenerate/QR actions with their URL and QR output.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const remoteSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteTailscaleAcceptRoutes",
|
||||
labelKey: "settings.remote.acceptRoutes",
|
||||
labelFallback: " Accept routes ",
|
||||
helpKey: "settings.remote.acceptRoutesHint",
|
||||
helpFallback: "Default: disabled.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"tailscale" is a keyword rather than left to the copy: the row only renders once Tailscale is the active provider, and its label and help say neither "tailscale" nor "subnet" — the words an operator searching for this actually types.
|
||||
*/
|
||||
keywords: ["tailscale", "subnet", "tailnet", "funnel"],
|
||||
},
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteShortLivedEnabled",
|
||||
labelKey: "settings.remote.enableShortLivedTokens",
|
||||
labelFallback: " Enable short-lived tokens ",
|
||||
helpKey: "settings.remote.shortLivedEnabledHint",
|
||||
helpFallback: "Default: disabled.",
|
||||
keywords: ["expiring token", "temporary access", "auth link"],
|
||||
},
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteShortLivedTtlMs",
|
||||
labelKey: "settings.remote.shortLivedTTLMs",
|
||||
labelFallback: "Short-lived TTL (ms)",
|
||||
helpKey: "settings.remote.shortLivedTtlMsHint",
|
||||
helpFallback: "Default: 900000 (15 minutes).",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The copy says "TTL" only; "expiry"/"lifetime" are what an operator who has not internalised the acronym searches for.
|
||||
*/
|
||||
keywords: ["expiry", "lifetime", "time to live", "token duration"],
|
||||
},
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteRememberLastRunning",
|
||||
labelKey: "settings.remote.rememberLastRunningState",
|
||||
labelFallback: " Remember last running state ",
|
||||
helpKey: "settings.remote.automaticallyRestoreTunnelOnStartupIfItWas",
|
||||
helpFallback:
|
||||
"Automatically restore tunnel on startup if it was running when last stopped. Default: disabled.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"tunnel" is in the help, but "cloudflared", "autostart", and "reconnect" are not — and they are how operators describe the behavior this setting controls.
|
||||
*/
|
||||
keywords: ["cloudflared", "autostart", "reconnect", "persist tunnel"],
|
||||
},
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteCloudflareTunnelName",
|
||||
labelKey: "settings.remote.tunnelName",
|
||||
labelFallback: "Tunnel name",
|
||||
keywords: ["cloudflare", "named tunnel", "cloudflared"],
|
||||
},
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteCloudflareTunnelToken",
|
||||
labelKey: "settings.remote.tunnelToken",
|
||||
labelFallback: "Tunnel token",
|
||||
// Label/help only; the token's value never enters the index.
|
||||
keywords: ["cloudflare", "named tunnel", "credential", "secret"],
|
||||
},
|
||||
{
|
||||
sectionId: "remote",
|
||||
key: "remoteCloudflareIngressUrl",
|
||||
labelKey: "settings.remote.ingressURL",
|
||||
labelFallback: "Ingress URL",
|
||||
keywords: ["cloudflare", "named tunnel", "hostname", "domain"],
|
||||
},
|
||||
];
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { updateRemoteSettings, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteUrl, fetchRemoteQr, type RemoteSettings, type RemoteStatus, } from "../../../api";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { SectionBaseProps, SettingsFormState } from "./context";
|
||||
export interface RemoteSectionData {
|
||||
projectId?: string;
|
||||
@@ -49,10 +52,24 @@ export interface RemoteSectionData {
|
||||
setRemoteQrSvg: (value: string | null) => void;
|
||||
}
|
||||
export interface RemoteSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
remote: RemoteSectionData;
|
||||
}
|
||||
export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Remote rows carry the "global" badge because these flattened form fields are not standalone settings keys: the shell's save-split (settings/save-split.ts, buildRemoteAccessPatch) folds them into the nested `remoteAccess` object, and `remoteAccess` is declared in DEFAULT_GLOBAL_SETTINGS. A tunnel belongs to the machine, not to one project, so the badge tells an operator these travel across every project on this node.
|
||||
Descriptor keys stay FLAT (`remoteShortLivedTtlMs`), not dotted, because flat is genuinely what `form.<key>` holds here — the flattening happens in the form, and the dotted-leaf idiom is only for sections that read `form.blob.leaf` directly.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Migrated rows are pulled OUT of their `form-group` wrappers instead of being nested inside them: `.form-group label` (narrowed further by `.settings-content .form-group label:not(.checkbox-label)`) out-specifies `.settings-field-row-label`, so a nested row would render its label uppercase/muted — the exact treatment the primitives retire. Wrappers are kept only around the bespoke content that still needs their inset.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Deliberately NOT migrated, and why:
|
||||
- The provider radio group (`remoteActiveProvider`) is a two-card radiogroup with provider icons, not a select.
|
||||
- `remoteCloudflareQuickTunnel` is driven by the Advanced disclosure's open/closed state, not by a checkbox.
|
||||
- The named-tunnel trio (`remoteCloudflareTunnelName`/`TunnelToken`/`IngressUrl`) stays whole inside that disclosure: `remoteCloudflareTunnelToken` is type="password" and SettingsTextRow hard-codes type="text", which would render the tunnel token UNMASKED. Splitting the other two out would strand the token alone.
|
||||
- The Auth Links block (`remoteAuthLinkTokenType` select) stays with the buttons and URL/QR output it configures; it is also local UI state, not a settings key.
|
||||
*/
|
||||
export function RemoteSection({ form, setForm, remote }: RemoteSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { projectId, addToast, remoteStatus, externalTunnel, tunnelShareLink, remoteBusyAction, cloudflaredInstalling, cloudflaredInstallError, cloudflaredManualInstallCommand, cloudflaredMacFallbackCommand, handleInstallCloudflared, runRemoteAction, remoteShortLivedToken, setRemoteShortLivedToken, remoteAuthLinkTokenType, setRemoteAuthLinkTokenType, remoteUrlPreview, setRemoteUrlPreview, remoteQrSvg, setRemoteQrSvg, } = remote;
|
||||
const remoteForm = form as Record<string, unknown>;
|
||||
@@ -84,7 +101,6 @@ export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSect
|
||||
};
|
||||
};
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.remote.remoteAccess", "Remote Access")}</h4>
|
||||
<div className={`remote-status-bar remote-status-bar--${statusColor}`}>
|
||||
<span className={`remote-status-dot remote-status-dot--${statusColor}`}/>
|
||||
@@ -159,6 +175,10 @@ export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSect
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Stays inline: this is the empty-state instruction for the whole provider block, shown only while nothing is picked, not help for one control. Behind a "?" the one operator who needs it — the one who has not chosen a provider yet — would never find it.
|
||||
*/}
|
||||
{!activeProvider && <small>{t("settings.remote.selectAProviderAboveToConfigureRemoteAccess", "Select a provider above to configure remote access.")}</small>}
|
||||
</div>
|
||||
|
||||
@@ -182,13 +202,22 @@ export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSect
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeProvider && (<div className="form-group remote-provider-settings">
|
||||
{activeProvider === "tailscale" ? (<>
|
||||
<small>{t("settings.remote.tailscaleFunnelWillExposeThisDashboardOnYour", "Tailscale Funnel will expose this dashboard on your tailnet's public ")}{`https://<machine>.<tailnet>.ts.net/`}{t("settings.remote.uRLNoHostnameOrPortConfigurationNeeded", " URL \u2014 no hostname or port configuration needed.")}</small>
|
||||
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
|
||||
<input id="remoteTailscaleAcceptRoutes" type="checkbox" checked={Boolean(remoteForm.remoteTailscaleAcceptRoutes)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: e.target.checked } as SettingsFormState))}/>{t("settings.remote.acceptRoutes", " Accept routes ")}</label>
|
||||
<small>{t("settings.remote.acceptRoutesHint", "Default: disabled.")}</small>
|
||||
</>) : (<>
|
||||
{activeProvider === "tailscale" && (<>
|
||||
<div className="form-group remote-provider-settings">
|
||||
<small>{t("settings.remote.tailscaleFunnelWillExposeThisDashboardOnYour", "Tailscale Funnel will expose this dashboard on your tailnet's public ")}{`https://<machine>.<tailnet>.ts.net/`}{t("settings.remote.uRLNoHostnameOrPortConfigurationNeeded", " URL \u2014 no hostname or port configuration needed.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "remoteTailscaleAcceptRoutes",
|
||||
label: t("settings.remote.acceptRoutes", " Accept routes "),
|
||||
help: t("settings.remote.acceptRoutesHint", "Default: disabled."),
|
||||
scope: "global",
|
||||
}}
|
||||
value={Boolean(remoteForm.remoteTailscaleAcceptRoutes)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: v === true } as SettingsFormState))}
|
||||
/>
|
||||
</>)}
|
||||
{activeProvider === "cloudflare" && (<div className="form-group remote-provider-settings">
|
||||
<small>
|
||||
{(remoteForm.remoteCloudflareQuickTunnel ?? true)
|
||||
? t("settings.remote.usingQuickTunnel", "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed. Default: enabled.")
|
||||
@@ -206,16 +235,46 @@ export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSect
|
||||
});
|
||||
}}>
|
||||
<summary>{t("settings.remote.advancedNamedTunnel", "Advanced (Named Tunnel)")}</summary>
|
||||
{/*
|
||||
FNXC:SettingsSecurity 2026-07-15-18:52:
|
||||
The tunnel token renders through `type: "password"` (masked, `autocomplete="off"` by default). Before the descriptor carried `type`, the shared row would have rendered it in plain text, which is why this whole trio stayed hand-rolled.
|
||||
`remoteCloudflareIngressUrl` keeps `type: "text"` even though it holds a URL: it was a text input before, and promoting it to `type="url"` would attach native URL validation and switch the mobile keyboard — a behavior change disguised as a refactor. The placeholder already communicates the format.
|
||||
These rows carry no help text of their own; the descriptor omits `help` rather than inventing copy.
|
||||
*/}
|
||||
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (<div className="remote-cf-advanced-fields">
|
||||
<label htmlFor="remoteCloudflareTunnelName">{t("settings.remote.tunnelName", "Tunnel name")}</label>
|
||||
<input id="remoteCloudflareTunnelName" type="text" placeholder={t("settings.remote.tunnelName", "Tunnel name")} value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))}/>
|
||||
<label htmlFor="remoteCloudflareTunnelToken">{t("settings.remote.tunnelToken", "Tunnel token")}</label>
|
||||
<input id="remoteCloudflareTunnelToken" type="password" placeholder={t("settings.remote.tunnelToken", "Tunnel token")} value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))}/>
|
||||
<label htmlFor="remoteCloudflareIngressUrl">{t("settings.remote.ingressURL", "Ingress URL")}</label>
|
||||
<input id="remoteCloudflareIngressUrl" type="text" placeholder={t("settings.remote.httpsYourDomainExample", "https://your-domain.example")} value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))}/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "remoteCloudflareTunnelName",
|
||||
label: t("settings.remote.tunnelName", "Tunnel name"),
|
||||
placeholder: t("settings.remote.tunnelName", "Tunnel name"),
|
||||
scope: "global",
|
||||
}}
|
||||
value={String(remoteForm.remoteCloudflareTunnelName ?? "")}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: v ?? "" } as SettingsFormState))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "remoteCloudflareTunnelToken",
|
||||
label: t("settings.remote.tunnelToken", "Tunnel token"),
|
||||
placeholder: t("settings.remote.tunnelToken", "Tunnel token"),
|
||||
type: "password",
|
||||
scope: "global",
|
||||
}}
|
||||
value={String(remoteForm.remoteCloudflareTunnelToken ?? "")}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: v ?? "" } as SettingsFormState))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "remoteCloudflareIngressUrl",
|
||||
label: t("settings.remote.ingressURL", "Ingress URL"),
|
||||
placeholder: t("settings.remote.httpsYourDomainExample", "https://your-domain.example"),
|
||||
scope: "global",
|
||||
}}
|
||||
value={String(remoteForm.remoteCloudflareIngressUrl ?? "")}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: v ?? "" } as SettingsFormState))}
|
||||
/>
|
||||
</div>) : null}
|
||||
</details>
|
||||
</>)}
|
||||
</div>)}
|
||||
|
||||
<div className="form-group remote-tunnel-actions">
|
||||
@@ -263,20 +322,46 @@ export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSect
|
||||
|
||||
<details className="remote-advanced-details">
|
||||
<summary>{t("settings.remote.advancedSettings", "Advanced Settings")}</summary>
|
||||
<div className="form-group">
|
||||
<label htmlFor="remoteShortLivedEnabled" className="checkbox-label">
|
||||
<input id="remoteShortLivedEnabled" type="checkbox" checked={Boolean(remoteForm.remoteShortLivedEnabled)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))}/>{t("settings.remote.enableShortLivedTokens", " Enable short-lived tokens ")}</label>
|
||||
<small>{t("settings.remote.shortLivedEnabledHint", "Default: disabled.")}</small>
|
||||
<label htmlFor="remoteShortLivedTtlMs">{t("settings.remote.shortLivedTTLMs", "Short-lived TTL (ms)")}</label>
|
||||
<input id="remoteShortLivedTtlMs" type="number" min={60000} max={86400000} value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))}/>
|
||||
<small>{t("settings.remote.shortLivedTtlMsHint", "Default: 900000 (15 minutes).")}</small>
|
||||
{remoteShortLivedToken && <small>{t("settings.remote.lastShortLivedTokenExpiresAt", "Last short-lived token expires at ")}{new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}{t("settings.remote.ms", "ms)")}</small>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="remoteRememberLastRunning" className="checkbox-label">
|
||||
<input id="remoteRememberLastRunning" type="checkbox" checked={Boolean(remoteForm.remoteRememberLastRunning)} onChange={(e) => setForm((f) => ({ ...f, remoteRememberLastRunning: e.target.checked } as SettingsFormState))}/>{t("settings.remote.rememberLastRunningState", " Remember last running state ")}</label>
|
||||
<small>{t("settings.remote.automaticallyRestoreTunnelOnStartupIfItWas", "Automatically restore tunnel on startup if it was running when last stopped. Default: disabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "remoteShortLivedEnabled",
|
||||
label: t("settings.remote.enableShortLivedTokens", " Enable short-lived tokens "),
|
||||
help: t("settings.remote.shortLivedEnabledHint", "Default: disabled."),
|
||||
scope: "global",
|
||||
}}
|
||||
value={Boolean(remoteForm.remoteShortLivedEnabled)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteShortLivedEnabled: v === true } as SettingsFormState))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:RemoteTokens 2026-07-15-17:35:
|
||||
The TTL stays enabled even when short-lived tokens are off: the Advanced "Generate short-lived token" / URL / QR actions below read this value directly, so it is live regardless of the toggle.
|
||||
A cleared field settles back to the 900000 default rather than to null — the token generators would otherwise mint a NaN TTL. Only an EMPTY field defaults: a typed 0 stores 0, matching the hand-rolled `Number(e.target.value || 900000)` where the string "0" is truthy and survives. Coercing 0 to the default here would silently rewrite an operator's input.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "remoteShortLivedTtlMs",
|
||||
label: t("settings.remote.shortLivedTTLMs", "Short-lived TTL (ms)"),
|
||||
help: t("settings.remote.shortLivedTtlMsHint", "Default: 900000 (15 minutes)."),
|
||||
scope: "global",
|
||||
min: 60000,
|
||||
max: 86400000,
|
||||
}}
|
||||
value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: v === null ? 900000 : v } as SettingsFormState))}
|
||||
/>
|
||||
{remoteShortLivedToken && (<div className="form-group">
|
||||
<small>{t("settings.remote.lastShortLivedTokenExpiresAt", "Last short-lived token expires at ")}{new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}{t("settings.remote.ms", "ms)")}</small>
|
||||
</div>)}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "remoteRememberLastRunning",
|
||||
label: t("settings.remote.rememberLastRunningState", " Remember last running state "),
|
||||
help: t("settings.remote.automaticallyRestoreTunnelOnStartupIfItWas", "Automatically restore tunnel on startup if it was running when last stopped. Default: disabled."),
|
||||
scope: "global",
|
||||
}}
|
||||
value={Boolean(remoteForm.remoteRememberLastRunning)}
|
||||
onChange={(v) => setForm((f) => ({ ...f, remoteRememberLastRunning: v === true } as SettingsFormState))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t("settings.remote.authLinks", "Auth Links")}</label>
|
||||
<div className="settings-button-row">
|
||||
@@ -303,13 +388,18 @@ export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSect
|
||||
setRemoteQrSvg(qr.data ?? null);
|
||||
})}>{t("settings.remote.generateQR", "Generate QR")}</button>
|
||||
</div>
|
||||
<label htmlFor="remoteAuthLinkTokenType">{t("settings.remote.authLinkTokenType", "Auth link token type")}</label>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
This select stays hand-rolled (it drives local UI state, not a settings key — see the note above), but its help moves behind the shared "?" anyway: a section that mixes rows with a help icon and rows with a paragraph reads as two different surfaces. The live TTL fragment rides along inside the tip because it qualifies the token-type choice rather than reporting a result.
|
||||
*/}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="remoteAuthLinkTokenType">{t("settings.remote.authLinkTokenType", "Auth link token type")}</label>
|
||||
<SettingsHelpTip settingKey="remoteAuthLinkTokenType">{t("settings.remote.uRLAndQRGenerationUseTheSelectedToken", " URL and QR generation use the selected token type. ")}{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="remoteAuthLinkTokenType" value={remoteAuthLinkTokenType} onChange={(e) => setRemoteAuthLinkTokenType(e.target.value as "persistent" | "short-lived")}>
|
||||
<option value="persistent">{t("settings.remote.persistentToken", "Persistent token")}</option>
|
||||
<option value="short-lived">{t("settings.remote.shortLivedToken", "Short-lived token")}</option>
|
||||
</select>
|
||||
<small>{t("settings.remote.uRLAndQRGenerationUseTheSelectedToken", " URL and QR generation use the selected token type. ")}{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}
|
||||
</small>
|
||||
{remoteUrlPreview?.url && (<>
|
||||
<small>{t("settings.remote.authenticatedURL", "Authenticated URL:")}<code className="settings-url-output">{remoteUrlPreview.url}</code></small>
|
||||
<small>{t("settings.remote.tokenType", " Token type: ")}<strong>{remoteUrlPreview.tokenType}</strong>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Search entries for the Research Defaults (global) section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. `settings-search-index.test.ts` fails the build if a descriptor `key` here and in ResearchGlobalSection.tsx ever diverge, which is what keeps the index honest without anyone maintaining a keyword list by hand.
|
||||
* Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The provider radio, limits grid, Enabled Sources grid, and credential empty-states stay bespoke and render no descriptor rows, so they carry no entries; the nav entry's own `searchableText` still surfaces the section for those.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const researchGlobalSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
This row lives behind the "Advanced — external search providers" disclosure, which is exactly why it is indexed: an operator who knows they want Brave or Tavily has no way to discover a control folded inside a closed `<details>`. The provider names are keywords because they are option labels, and only label and help are indexed automatically.
|
||||
*/
|
||||
sectionId: "research-global",
|
||||
key: "researchGlobalWebSearchProvider",
|
||||
labelKey: "settings.researchGlobal.searchProvider",
|
||||
labelFallback: "Search Provider",
|
||||
keywords: ["SearXNG", "Brave", "Google Custom Search", "Tavily", "external search providers", "built-in"],
|
||||
},
|
||||
{
|
||||
sectionId: "research-global",
|
||||
key: "researchGlobalSearxngUrl",
|
||||
labelKey: "settings.researchGlobal.searXNGURL",
|
||||
labelFallback: "SearXNG URL",
|
||||
helpKey: "settings.researchGlobal.searXNGURLHint",
|
||||
helpFallback: "No default — unset.",
|
||||
keywords: ["endpoint", "instance", "self-hosted"],
|
||||
},
|
||||
{
|
||||
sectionId: "research-global",
|
||||
key: "researchGlobalGoogleSearchCx",
|
||||
labelKey: "settings.researchGlobal.googleSearchCX",
|
||||
labelFallback: "Google Search CX",
|
||||
helpKey: "settings.researchGlobal.googleSearchCXHint",
|
||||
helpFallback: "No default — unset.",
|
||||
keywords: ["custom search engine id", "programmable search"],
|
||||
},
|
||||
];
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import type { AuthProvider } from "../../../api";
|
||||
import type { SectionId } from "../../SettingsModal";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface ResearchGlobalSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
authProviders: AuthProvider[];
|
||||
onNavigateToSection: (section: SectionId) => void;
|
||||
}
|
||||
export function ResearchGlobalSection({ scopeBanner, form, setForm, authProviders, onNavigateToSection, }: ResearchGlobalSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The plain label+control+help rows inside the advanced disclosure render through the shared settings primitives instead of hand-rolled `form-group` markup, so their labels, help copy, and padding come from the one settings type scale. `.form-group` itself stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath the rest of the dashboard.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Every migrated key here is global (`DEFAULT_GLOBAL_SETTINGS`): a search provider and its endpoint/engine ids are machine-wide research credentials-adjacent config, not per-repository policy. The badges restate that per row because settings search can land an operator on a single control with no section chrome in view.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Four groups deliberately keep their bespoke markup because they are not plain label+control+help rows: the built-in/external provider radio and its `<details>` disclosure, the limits grid (`settings-research-limit-field`), the Enabled Sources grid pairing an always-on locked Web Search row with per-source inline hints, and the two credential empty-state notes that carry navigation buttons.
|
||||
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
The bespoke rows that are still one control + one help string — the built-in provider radio and each limits field — hang that help off the same "?" as the migrated rows above (`.settings-field-label-row` + `SettingsHelpTip`), so a limits grid of five "Default: N." paragraphs no longer sits beside rows whose help is behind an icon.
|
||||
Two kinds of copy stay inline here: the Enabled Sources hints, which annotate a checkbox grid rather than describe one control, and the credential empty-state/alert notes, which are live credential status plus a navigation button the operator must actually see.
|
||||
*/
|
||||
export function ResearchGlobalSection({ form, setForm, authProviders, onNavigateToSection, }: ResearchGlobalSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const resolvedProvider = form.researchGlobalWebSearchProvider ??
|
||||
form.researchGlobalDefaults?.searchProvider ??
|
||||
@@ -33,40 +48,64 @@ export function ResearchGlobalSection({ scopeBanner, form, setForm, authProvider
|
||||
}));
|
||||
};
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.researchGlobal.researchDefaults", "Research Defaults")}</h4>
|
||||
<div className="form-group settings-research-provider-group">
|
||||
<label htmlFor="research-global-provider-builtin" className="checkbox-label">
|
||||
<input id="research-global-provider-builtin" type="radio" name="research-global-search-provider" checked={!externalProvider} onChange={() => setSearchProvider("builtin")}/>{t("settings.researchGlobal.builtInUsesAgentWebTools", " Built-in (uses agent web tools) (default) ")}</label>
|
||||
<small>{t("settings.researchGlobal.searchesAndFetchesUseTheAgentsNativeWebSearch", " Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. Default: builtin. ")}</small>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-global-provider-builtin" className="checkbox-label">
|
||||
<input id="research-global-provider-builtin" type="radio" name="research-global-search-provider" checked={!externalProvider} onChange={() => setSearchProvider("builtin")}/>{t("settings.researchGlobal.builtInUsesAgentWebTools", " Built-in (uses agent web tools) (default) ")}</label>
|
||||
<SettingsHelpTip settingKey="research-global-provider-builtin">{t("settings.researchGlobal.searchesAndFetchesUseTheAgentsNativeWebSearch", " Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. Default: builtin. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<details className="settings-option-details settings-research-provider-advanced-details">
|
||||
<summary>{t("settings.researchGlobal.advancedExternalSearchProviders", "Advanced \u2014 external search providers")}</summary>
|
||||
<div className="settings-research-provider-advanced-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-search-provider-advanced">{t("settings.researchGlobal.searchProvider", "Search Provider")}</label>
|
||||
<select id="research-global-search-provider-advanced" className="input" value={externalProvider ? resolvedProvider : "searxng"} onChange={(event) => setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])}>
|
||||
<option value="searxng">{t("settings.researchGlobal.searXNG", "SearXNG")}</option>
|
||||
<option value="brave">{t("settings.researchGlobal.brave", "Brave")}</option>
|
||||
<option value="google">{t("settings.researchGlobal.googleCustomSearch", "Google Custom Search")}</option>
|
||||
<option value="tavily">{t("settings.researchGlobal.tavily", "Tavily")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-searxng-url">{t("settings.researchGlobal.searXNGURL", "SearXNG URL")}</label>
|
||||
<input id="research-global-searxng-url" className="input" value={form.researchGlobalSearxngUrl ?? ""} onChange={(event) => setForm((current) => ({
|
||||
{/*
|
||||
FNXC:ResearchProviders 2026-07-15-17:35:
|
||||
The select shows `searxng` while the built-in radio is chosen so the disclosure always presents a concrete external option to switch to; picking any option here is what flips the radio off, since both controls write the same `researchGlobalWebSearchProvider` key.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "researchGlobalWebSearchProvider",
|
||||
label: t("settings.researchGlobal.searchProvider", "Search Provider"),
|
||||
scope: "global",
|
||||
options: [
|
||||
{ value: "searxng", label: t("settings.researchGlobal.searXNG", "SearXNG") },
|
||||
{ value: "brave", label: t("settings.researchGlobal.brave", "Brave") },
|
||||
{ value: "google", label: t("settings.researchGlobal.googleCustomSearch", "Google Custom Search") },
|
||||
{ value: "tavily", label: t("settings.researchGlobal.tavily", "Tavily") },
|
||||
],
|
||||
}}
|
||||
value={externalProvider ? resolvedProvider : "searxng"}
|
||||
onChange={(v) => setSearchProvider(v as Settings["researchGlobalWebSearchProvider"])}
|
||||
/>
|
||||
{/* FNXC:ResearchProviders 2026-07-15-17:35: An emptied endpoint/engine id stores `undefined`, not "", so the key is absent from the settings blob and the provider falls back to unset rather than being configured with a blank URL. */}
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "researchGlobalSearxngUrl",
|
||||
label: t("settings.researchGlobal.searXNGURL", "SearXNG URL"),
|
||||
help: t("settings.researchGlobal.searXNGURLHint", "No default \u2014 unset."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.researchGlobal.httpsSearxExampleCom", "https://searx.example.com"),
|
||||
}}
|
||||
value={form.researchGlobalSearxngUrl ?? null}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalSearxngUrl: event.target.value || undefined,
|
||||
}))} placeholder={t("settings.researchGlobal.httpsSearxExampleCom", "https://searx.example.com")}/>
|
||||
<small>{t("settings.researchGlobal.searXNGURLHint", "No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-google-cx">{t("settings.researchGlobal.googleSearchCX", "Google Search CX")}</label>
|
||||
<input id="research-global-google-cx" className="input" value={form.researchGlobalGoogleSearchCx ?? ""} onChange={(event) => setForm((current) => ({
|
||||
researchGlobalSearxngUrl: v || undefined,
|
||||
}))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "researchGlobalGoogleSearchCx",
|
||||
label: t("settings.researchGlobal.googleSearchCX", "Google Search CX"),
|
||||
help: t("settings.researchGlobal.googleSearchCXHint", "No default \u2014 unset."),
|
||||
scope: "global",
|
||||
placeholder: t("settings.researchGlobal.customSearchEngineId", "custom-search-engine-id"),
|
||||
}}
|
||||
value={form.researchGlobalGoogleSearchCx ?? null}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalGoogleSearchCx: event.target.value || undefined,
|
||||
}))} placeholder={t("settings.researchGlobal.customSearchEngineId", "custom-search-engine-id")}/>
|
||||
<small>{t("settings.researchGlobal.googleSearchCXHint", "No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
researchGlobalGoogleSearchCx: v || undefined,
|
||||
}))}
|
||||
/>
|
||||
<div className="settings-empty-state settings-research-empty-state" role="note">{t("settings.researchGlobal.configureBraveTavilyAndGoogleAPIKeysIn", " Configure Brave, Tavily, and Google API keys in Authentication. ")}<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>{t("settings.researchGlobal.openAuthenticationSettings", " Open Authentication Settings ")}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,15 +114,20 @@ export function ResearchGlobalSection({ scopeBanner, form, setForm, authProvider
|
||||
<div className="form-group">
|
||||
<div className="settings-research-limits-grid">
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-global-max-concurrent">{t("settings.researchGlobal.defaultMaxConcurrentRuns", "Default Max Concurrent Runs")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-global-max-concurrent">{t("settings.researchGlobal.defaultMaxConcurrentRuns", "Default Max Concurrent Runs")}</label>
|
||||
<SettingsHelpTip settingKey="research-global-max-concurrent">{t("settings.researchGlobal.maxConcurrentRunsHint", "Default: 3.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-global-max-concurrent" className="input" type="number" min={1} value={form.researchGlobalMaxConcurrentRuns ?? 3} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
}))}/>
|
||||
<small>{t("settings.researchGlobal.maxConcurrentRunsHint", "Default: 3.")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-global-max-sources">{t("settings.researchGlobal.defaultMaxSourcesPerRun", "Default Max Sources Per Run")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-global-max-sources">{t("settings.researchGlobal.defaultMaxSourcesPerRun", "Default Max Sources Per Run")}</label>
|
||||
<SettingsHelpTip settingKey="research-global-max-sources">{t("settings.researchGlobal.maxSourcesPerRunHint", "Default: 20.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-global-max-sources" className="input" type="number" min={1} value={form.researchGlobalMaxSourcesPerRun ?? 20} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
@@ -92,31 +136,36 @@ export function ResearchGlobalSection({ scopeBanner, form, setForm, authProvider
|
||||
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.researchGlobal.maxSourcesPerRunHint", "Default: 20.")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-global-default-timeout">{t("settings.researchGlobal.defaultMaxDurationMs", "Default Max Duration (ms)")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-global-default-timeout">{t("settings.researchGlobal.defaultMaxDurationMs", "Default Max Duration (ms)")}</label>
|
||||
<SettingsHelpTip settingKey="research-global-default-timeout">{t("settings.researchGlobal.defaultMaxDurationMsHint", "Default: 300000 (5 minutes).")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-global-default-timeout" className="input" type="number" min={1000} value={form.researchGlobalDefaultTimeout ?? 300000} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
}))}/>
|
||||
<small>{t("settings.researchGlobal.defaultMaxDurationMsHint", "Default: 300000 (5 minutes).")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-global-fetch-timeout">{t("settings.researchGlobal.requestTimeoutMs", "Request Timeout (ms)")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-global-fetch-timeout">{t("settings.researchGlobal.requestTimeoutMs", "Request Timeout (ms)")}</label>
|
||||
<SettingsHelpTip settingKey="research-global-fetch-timeout">{t("settings.researchGlobal.requestTimeoutMsHint", "Default: 30000 (30 seconds).")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-global-fetch-timeout" className="input" type="number" min={1000} value={form.researchGlobalFetchTimeoutMs ?? 30000} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
}))}/>
|
||||
<small>{t("settings.researchGlobal.requestTimeoutMsHint", "Default: 30000 (30 seconds).")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-global-max-synthesis-rounds">{t("settings.researchGlobal.maxSynthesisRounds", "Max Synthesis Rounds")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-global-max-synthesis-rounds">{t("settings.researchGlobal.maxSynthesisRounds", "Max Synthesis Rounds")}</label>
|
||||
<SettingsHelpTip settingKey="research-global-max-synthesis-rounds">{t("settings.researchGlobal.maxSynthesisRoundsHint", "Default: 2.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-global-max-synthesis-rounds" className="input" type="number" min={1} value={form.researchGlobalMaxSynthesisRounds ?? 2} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
}))}/>
|
||||
<small>{t("settings.researchGlobal.maxSynthesisRoundsHint", "Default: 2.")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Search entries for the project Research section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. `settings-search-index.test.ts` fails the build if a descriptor `key` here and in ResearchProjectSection.tsx ever diverge, which is what keeps the index honest without anyone maintaining a keyword list by hand.
|
||||
* Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The Enabled Sources grid and the limits grid stay bespoke and render no descriptor rows, so they carry no entries; the nav entry's own `searchableText` still surfaces the section for those.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const researchProjectSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "research-project",
|
||||
key: "researchSettings.enabled",
|
||||
labelKey: "settings.researchProject.enableResearchInThisProject",
|
||||
labelFallback: " Enable research in this project ",
|
||||
helpKey: "settings.researchProject.enableResearchInThisProjectHint",
|
||||
helpFallback: "Default: enabled.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The copy is two words long and never names what it gates, so the master switch is unreachable by every term an operator would actually search. These keywords are the vocabulary of the feature it turns off, not a restatement of the label.
|
||||
*/
|
||||
keywords: ["research", "web search", "citations", "sources", "turn off research"],
|
||||
},
|
||||
];
|
||||
@@ -1,28 +1,49 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface ResearchProjectSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
researchLimitError: string | null;
|
||||
}
|
||||
export function ResearchProjectSection({ scopeBanner, form, setForm, researchLimitError }: ResearchProjectSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The section's one plain label+control+help row renders through the shared settings primitives instead of hand-rolled `form-group` + `checkbox-label` markup, so its label, help copy, and padding come from the one settings type scale. `.form-group` itself stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath the rest of the dashboard.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
`researchSettings` lives in `DEFAULT_PROJECT_SETTINGS`, so the master toggle carries a project badge: research enablement describes one repository's research policy and must not follow the operator to another project.
|
||||
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
The descriptor key is the dotted path `researchSettings.enabled`, not the bare `researchSettings` blob key, because the toggle owns exactly one leaf of that nested object. The sources and limits below own their own leaves of the same blob, so a bare key would collide the moment either migrates, and search would anchor several controls to one row.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Two groups deliberately keep their bespoke markup because they are not plain label+control+help rows: the Enabled Sources grid pairs an always-on locked Web Search row with a checkbox grid carrying inline per-source default hints, and the limits grid lays four numeric fields plus a shared validation error out side by side (`settings-research-limit-field`).
|
||||
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Each limits field is still one control with one help string, so its "Default: N." hangs off the same "?" as the migrated toggle above (`.settings-field-label-row` + `SettingsHelpTip`) rather than printing four paragraphs under a section whose other row hides its help behind an icon.
|
||||
Two things stay inline: the shared limits validation error (a message the operator has to open a tip to find is one they will not see) and the Enabled Sources copy — the always-on Web Search note points at another section, and the per-source hints annotate a checkbox grid rather than describe one control.
|
||||
*/
|
||||
export function ResearchProjectSection({ form, setForm, researchLimitError }: ResearchProjectSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const limits = form.researchSettings?.limits;
|
||||
const sources = form.researchSettings?.enabledSources;
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.researchProject.projectResearchSettings", "Project Research Settings")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-project-enabled" className="checkbox-label">
|
||||
<input id="research-project-enabled" type="checkbox" checked={form.researchSettings?.enabled ?? true} onChange={(event) => setForm((current) => ({
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "researchSettings.enabled",
|
||||
label: t("settings.researchProject.enableResearchInThisProject", " Enable research in this project "),
|
||||
help: t("settings.researchProject.enableResearchInThisProjectHint", "Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.researchSettings?.enabled ?? true}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
researchSettings: {
|
||||
...(current.researchSettings ?? {}),
|
||||
enabled: event.target.checked,
|
||||
enabled: v === true,
|
||||
},
|
||||
}))}/>{t("settings.researchProject.enableResearchInThisProject", " Enable research in this project ")}</label>
|
||||
<small>{t("settings.researchProject.enableResearchInThisProjectHint", "Default: enabled.")}</small>
|
||||
</div>
|
||||
}))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t("settings.researchProject.enabledSources", "Enabled Sources")}</label>
|
||||
<label htmlFor="research-project-source-webSearch" className="checkbox-label settings-research-source-locked">
|
||||
@@ -54,7 +75,10 @@ export function ResearchProjectSection({ scopeBanner, form, setForm, researchLim
|
||||
<div className="form-group">
|
||||
<div className="settings-research-limits-grid">
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-project-max-concurrent">{t("settings.researchProject.maxConcurrentRuns", "Max Concurrent Runs")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-project-max-concurrent">{t("settings.researchProject.maxConcurrentRuns", "Max Concurrent Runs")}</label>
|
||||
<SettingsHelpTip settingKey="research-project-max-concurrent">{t("settings.researchProject.maxConcurrentRunsHint", "Default: 3.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-project-max-concurrent" className="input" type="number" min={1} value={limits?.maxConcurrentRuns ?? 3} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchSettings: {
|
||||
@@ -65,10 +89,12 @@ export function ResearchProjectSection({ scopeBanner, form, setForm, researchLim
|
||||
},
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.researchProject.maxConcurrentRunsHint", "Default: 3.")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-project-max-sources">{t("settings.researchProject.maxSourcesPerRun", "Max Sources Per Run")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-project-max-sources">{t("settings.researchProject.maxSourcesPerRun", "Max Sources Per Run")}</label>
|
||||
<SettingsHelpTip settingKey="research-project-max-sources">{t("settings.researchProject.maxSourcesPerRunHint", "Default: 20.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-project-max-sources" className="input" type="number" min={1} value={limits?.maxSourcesPerRun ?? 20} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchSettings: {
|
||||
@@ -79,10 +105,12 @@ export function ResearchProjectSection({ scopeBanner, form, setForm, researchLim
|
||||
},
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.researchProject.maxSourcesPerRunHint", "Default: 20.")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-project-max-duration">{t("settings.researchProject.maxDurationMs", "Max Duration (ms)")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-project-max-duration">{t("settings.researchProject.maxDurationMs", "Max Duration (ms)")}</label>
|
||||
<SettingsHelpTip settingKey="research-project-max-duration">{t("settings.researchProject.maxDurationMsHint", "Default: 300000 (5 minutes).")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-project-max-duration" className="input" type="number" min={1000} value={limits?.maxDurationMs ?? 300000} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchSettings: {
|
||||
@@ -93,10 +121,12 @@ export function ResearchProjectSection({ scopeBanner, form, setForm, researchLim
|
||||
},
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.researchProject.maxDurationMsHint", "Default: 300000 (5 minutes).")}</small>
|
||||
</div>
|
||||
<div className="settings-research-limit-field">
|
||||
<label htmlFor="research-project-request-timeout">{t("settings.researchProject.requestTimeoutMs", "Request Timeout (ms)")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="research-project-request-timeout">{t("settings.researchProject.requestTimeoutMs", "Request Timeout (ms)")}</label>
|
||||
<SettingsHelpTip settingKey="research-project-request-timeout">{t("settings.researchProject.requestTimeoutMsHint", "Default: 30000 (30 seconds).")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="research-project-request-timeout" className="input" type="number" min={1000} value={limits?.requestTimeoutMs ?? 30000} onChange={(event) => setForm((current) => ({
|
||||
...current,
|
||||
researchSettings: {
|
||||
@@ -107,8 +137,8 @@ export function ResearchProjectSection({ scopeBanner, form, setForm, researchLim
|
||||
},
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.researchProject.requestTimeoutMsHint", "Default: 30000 (30 seconds).")}</small>
|
||||
</div>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-21:40: The validation error stays inline while the fields' help moved behind the "?" — an error the operator has to open a tip to read is an error they will not see. */}
|
||||
{researchLimitError && <small className="field-error settings-research-limits-error">{researchLimitError}</small>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Search entries for the Scheduled Evals section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim — search matches the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* Keys are the dotted `evalSettings.*` paths the section's descriptors declare: the index matches on `key` as the row's scroll anchor, so it must be the same string the descriptor renders, not the enclosing blob name.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const scheduledEvalsSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "scheduled-evals",
|
||||
key: "evalSettings.enabled",
|
||||
labelKey: "settings.scheduledEvals.enableScheduledEvalRunsForThisProject",
|
||||
labelFallback: " Enable scheduled eval runs for this project ",
|
||||
helpKey: "settings.scheduledEvals.enabledHint",
|
||||
helpFallback: "Default: disabled.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
"eval" is the product's word; operators arriving from the quality side search "evaluation" or "benchmark", neither of which appears in this control's copy.
|
||||
*/
|
||||
keywords: ["evaluation", "benchmark", "quality"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduled-evals",
|
||||
key: "evalSettings.intervalMs",
|
||||
labelKey: "settings.scheduledEvals.intervalMs",
|
||||
labelFallback: "Interval (ms)",
|
||||
helpKey: "settings.scheduledEvals.intervalMsHint",
|
||||
helpFallback: "Default: 86400000 (24 hours).",
|
||||
keywords: ["frequency", "how often", "schedule"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduled-evals",
|
||||
key: "evalSettings.evaluatorProvider",
|
||||
labelKey: "settings.scheduledEvals.evaluatorProvider",
|
||||
labelFallback: "Evaluator Provider",
|
||||
helpKey: "settings.scheduledEvals.evaluatorProviderHint",
|
||||
helpFallback: "No default — unset (inherits the project validator lane provider).",
|
||||
},
|
||||
{
|
||||
sectionId: "scheduled-evals",
|
||||
key: "evalSettings.evaluatorModelId",
|
||||
labelKey: "settings.scheduledEvals.evaluatorModel",
|
||||
labelFallback: "Evaluator Model",
|
||||
helpKey: "settings.scheduledEvals.leaveProviderAndModelBlankToInheritThe",
|
||||
helpFallback:
|
||||
" Leave provider and model blank to inherit the project validator lane model settings. No default — unset. ",
|
||||
},
|
||||
{
|
||||
sectionId: "scheduled-evals",
|
||||
key: "evalSettings.followUpPolicy",
|
||||
labelKey: "settings.scheduledEvals.followUpPolicy",
|
||||
labelFallback: "Follow-up Policy",
|
||||
helpKey: "settings.scheduledEvals.followUpPolicyHint",
|
||||
helpFallback: "Default: suggest only.",
|
||||
keywords: ["auto-create tasks", "suggestions"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduled-evals",
|
||||
key: "evalSettings.retentionDays",
|
||||
labelKey: "settings.scheduledEvals.retentionDays",
|
||||
labelFallback: "Retention (days)",
|
||||
helpKey: "settings.scheduledEvals.retentionDaysHint",
|
||||
helpFallback: "Default: 30.",
|
||||
keywords: ["prune", "cleanup", "history"],
|
||||
},
|
||||
];
|
||||
@@ -1,86 +1,138 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export interface ScheduledEvalsSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
}
|
||||
export function ScheduledEvalsSection({ scopeBanner, form, setForm }: ScheduledEvalsSectionProps) {
|
||||
export type ScheduledEvalsSectionProps = SectionBaseProps;
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Eval scheduling is project-scoped (`evalSettings` in DEFAULT_PROJECT_SETTINGS): each project schedules its own runs against its own validator lane, so nothing here travels between projects.
|
||||
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Descriptor keys are dotted paths (`evalSettings.enabled`) because the six controls share one stored blob. The key must stay unique per row — it is both the control's element id and the row's search anchor — and the dotted path is the honest name of what each row writes, so an operator searching the config field name still lands on the right control.
|
||||
|
||||
FNXC:ScheduledEvals 2026-07-15-17:35:
|
||||
Interval, follow-up policy, and retention are disabled while scheduling is off, but provider and model deliberately are not: they are inherited-lane overrides an operator can stage before ever enabling runs.
|
||||
*/
|
||||
export function ScheduledEvalsSection({ form, setForm }: ScheduledEvalsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const evalSettings = form.evalSettings ?? {};
|
||||
const isScheduledEvalEnabled = evalSettings.enabled ?? false;
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.scheduledEvals.scheduledEvals", "Scheduled Evals")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="scheduled-evals-enabled" className="checkbox-label">
|
||||
<input id="scheduled-evals-enabled" type="checkbox" checked={isScheduledEvalEnabled} onChange={(event) => setForm((current) => ({
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "evalSettings.enabled",
|
||||
label: t("settings.scheduledEvals.enableScheduledEvalRunsForThisProject", " Enable scheduled eval runs for this project "),
|
||||
help: t("settings.scheduledEvals.enabledHint", "Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={isScheduledEvalEnabled}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
evalSettings: {
|
||||
...(current.evalSettings ?? {}),
|
||||
enabled: event.target.checked,
|
||||
enabled: v === true,
|
||||
},
|
||||
}))}/>{t("settings.scheduledEvals.enableScheduledEvalRunsForThisProject", " Enable scheduled eval runs for this project ")}</label>
|
||||
<small>{t("settings.scheduledEvals.enabledHint", "Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="scheduled-evals-interval">{t("settings.scheduledEvals.intervalMs", "Interval (ms)")}</label>
|
||||
<input id="scheduled-evals-interval" className="input" type="number" min={60000} max={604800000} step={1000} disabled={!isScheduledEvalEnabled} value={evalSettings.intervalMs ?? 86400000} onChange={(event) => setForm((current) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "evalSettings.intervalMs",
|
||||
label: t("settings.scheduledEvals.intervalMs", "Interval (ms)"),
|
||||
help: t("settings.scheduledEvals.intervalMsHint", "Default: 86400000 (24 hours)."),
|
||||
scope: "project",
|
||||
disabled: !isScheduledEvalEnabled,
|
||||
min: 60000,
|
||||
max: 604800000,
|
||||
step: 1000,
|
||||
}}
|
||||
value={evalSettings.intervalMs ?? 86400000}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
evalSettings: {
|
||||
...(current.evalSettings ?? {}),
|
||||
intervalMs: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
intervalMs: v === null ? undefined : v,
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.scheduledEvals.intervalMsHint", "Default: 86400000 (24 hours).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="scheduled-evals-provider">{t("settings.scheduledEvals.evaluatorProvider", "Evaluator Provider")}</label>
|
||||
<input id="scheduled-evals-provider" className="input" value={evalSettings.evaluatorProvider ?? ""} onChange={(event) => setForm((current) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "evalSettings.evaluatorProvider",
|
||||
label: t("settings.scheduledEvals.evaluatorProvider", "Evaluator Provider"),
|
||||
help: t("settings.scheduledEvals.evaluatorProviderHint", "No default — unset (inherits the project validator lane provider)."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.scheduledEvals.openai", "openai"),
|
||||
}}
|
||||
value={evalSettings.evaluatorProvider ?? ""}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
evalSettings: {
|
||||
...(current.evalSettings ?? {}),
|
||||
evaluatorProvider: event.target.value.trim() === "" ? undefined : event.target.value,
|
||||
evaluatorProvider: (v ?? "").trim() === "" ? undefined : (v ?? undefined),
|
||||
},
|
||||
}))} placeholder={t("settings.scheduledEvals.openai", "openai")}/>
|
||||
<small>{t("settings.scheduledEvals.evaluatorProviderHint", "No default \u2014 unset (inherits the project validator lane provider).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="scheduled-evals-model">{t("settings.scheduledEvals.evaluatorModel", "Evaluator Model")}</label>
|
||||
<input id="scheduled-evals-model" className="input" value={evalSettings.evaluatorModelId ?? ""} onChange={(event) => setForm((current) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "evalSettings.evaluatorModelId",
|
||||
label: t("settings.scheduledEvals.evaluatorModel", "Evaluator Model"),
|
||||
help: t("settings.scheduledEvals.leaveProviderAndModelBlankToInheritThe", " Leave provider and model blank to inherit the project validator lane model settings. No default — unset. "),
|
||||
scope: "project",
|
||||
placeholder: t("settings.scheduledEvals.gpt5", "gpt-5"),
|
||||
}}
|
||||
value={evalSettings.evaluatorModelId ?? ""}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
evalSettings: {
|
||||
...(current.evalSettings ?? {}),
|
||||
evaluatorModelId: event.target.value.trim() === "" ? undefined : event.target.value,
|
||||
evaluatorModelId: (v ?? "").trim() === "" ? undefined : (v ?? undefined),
|
||||
},
|
||||
}))} placeholder={t("settings.scheduledEvals.gpt5", "gpt-5")}/>
|
||||
<small className="form-text text-muted">{t("settings.scheduledEvals.leaveProviderAndModelBlankToInheritThe", " Leave provider and model blank to inherit the project validator lane model settings. No default \u2014 unset. ")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="scheduled-evals-follow-up-policy">{t("settings.scheduledEvals.followUpPolicy", "Follow-up Policy")}</label>
|
||||
<select id="scheduled-evals-follow-up-policy" className="select" disabled={!isScheduledEvalEnabled} value={evalSettings.followUpPolicy ?? "suggest-only"} onChange={(event) => setForm((current) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "evalSettings.followUpPolicy",
|
||||
label: t("settings.scheduledEvals.followUpPolicy", "Follow-up Policy"),
|
||||
help: t("settings.scheduledEvals.followUpPolicyHint", "Default: suggest only."),
|
||||
scope: "project",
|
||||
disabled: !isScheduledEvalEnabled,
|
||||
options: [
|
||||
{ value: "disabled", label: t("settings.scheduledEvals.disabled", "Disabled") },
|
||||
{ value: "suggest-only", label: t("settings.scheduledEvals.suggestOnly", "Suggest only") },
|
||||
{ value: "auto-create", label: t("settings.scheduledEvals.autoCreateTasks", "Auto-create tasks") },
|
||||
],
|
||||
}}
|
||||
value={evalSettings.followUpPolicy ?? "suggest-only"}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
evalSettings: {
|
||||
...(current.evalSettings ?? {}),
|
||||
followUpPolicy: event.target.value as "disabled" | "suggest-only" | "auto-create",
|
||||
followUpPolicy: v as "disabled" | "suggest-only" | "auto-create",
|
||||
},
|
||||
}))}>
|
||||
<option value="disabled">{t("settings.scheduledEvals.disabled", "Disabled")}</option>
|
||||
<option value="suggest-only">{t("settings.scheduledEvals.suggestOnly", "Suggest only")}</option>
|
||||
<option value="auto-create">{t("settings.scheduledEvals.autoCreateTasks", "Auto-create tasks")}</option>
|
||||
</select>
|
||||
<small>{t("settings.scheduledEvals.followUpPolicyHint", "Default: suggest only.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="scheduled-evals-retention-days">{t("settings.scheduledEvals.retentionDays", "Retention (days)")}</label>
|
||||
<input id="scheduled-evals-retention-days" className="input" type="number" min={1} max={365} step={1} disabled={!isScheduledEvalEnabled} value={evalSettings.retentionDays ?? 30} onChange={(event) => setForm((current) => ({
|
||||
}))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "evalSettings.retentionDays",
|
||||
label: t("settings.scheduledEvals.retentionDays", "Retention (days)"),
|
||||
help: t("settings.scheduledEvals.retentionDaysHint", "Default: 30."),
|
||||
scope: "project",
|
||||
disabled: !isScheduledEvalEnabled,
|
||||
min: 1,
|
||||
max: 365,
|
||||
step: 1,
|
||||
}}
|
||||
value={evalSettings.retentionDays ?? 30}
|
||||
onChange={(v) => setForm((current) => ({
|
||||
...current,
|
||||
evalSettings: {
|
||||
...(current.evalSettings ?? {}),
|
||||
retentionDays: event.target.value === "" ? undefined : Number(event.target.value),
|
||||
retentionDays: v === null ? undefined : v,
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.scheduledEvals.retentionDaysHint", "Default: 30.")}</small>
|
||||
</div>
|
||||
}))}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
export default ScheduledEvalsSection;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Search entries for the Scheduling · Global section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-18:52:
|
||||
* `globalMaxConcurrent` moved here with its control when Scheduling was split into a Global/Project pair. The entry's `sectionId` must track the section that actually RENDERS the row — a stale id would surface the result, jump to a section that no longer holds the anchor, and do nothing.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const schedulingGlobalSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "scheduling-global",
|
||||
key: "globalMaxConcurrent",
|
||||
labelKey: "settings.scheduling.globalMaxConcurrent",
|
||||
labelFallback: "Global Max Concurrent",
|
||||
helpKey: "settings.scheduling.maximumConcurrentAgentsAcrossAllProjects",
|
||||
helpFallback: "Maximum concurrent agents across all projects. Default: 4.",
|
||||
keywords: ["parallelism", "capacity", "machine wide", "cap"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
|
||||
export interface SchedulingGlobalSectionProps {
|
||||
globalMaxConcurrent: number | undefined;
|
||||
concurrencyLoading?: boolean;
|
||||
onGlobalMaxConcurrentChange: (value: number | undefined) => void;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
The machine-wide concurrency cap gets its own section instead of sitting on top of the project scheduling settings behind an in-section "Global — applies to all projects" subheading.
|
||||
One section held two authority levels, so the answer to "does this affect other projects?" depended on which subheading an operator had scrolled past — and a search result landing mid-section shows no subheading at all. Sections are now single-scope, and the Global/Project pair sits adjacent under Automation, matching how Models/MCP/Research/General already read.
|
||||
This split is also what lets the sibling project section drop its ScopeGroupHeader chrome entirely.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
The row deliberately carries NO scope badge. `globalMaxConcurrent` is the one place the schema and the UI genuinely disagree: it is declared in `DEFAULT_PROJECT_SETTINGS` (settings-schema.ts:359) yet is read and written through the dedicated global-concurrency endpoint (hence the prop rather than `form`) and applies to every project on the machine.
|
||||
Stamping "project" would contradict the section it lives in; stamping "global" would contradict the schema. The section is the honest source of scope until the schema is fixed — a badge here would assert a fact the data model does not support.
|
||||
*/
|
||||
export function SchedulingGlobalSection({ globalMaxConcurrent, concurrencyLoading = false, onGlobalMaxConcurrentChange, }: SchedulingGlobalSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
<h4 className="settings-section-heading">{t("settings.scheduling.scopeGlobalTitle", "Global — applies to all projects")}</h4>
|
||||
<p className="settings-section-description">{t("settings.scheduling.scopeGlobalCaption", "Shared by every project on this machine.")}</p>
|
||||
{/*
|
||||
FNXC:SettingsConcurrency 2026-06-22-20:18:
|
||||
Concurrency inputs represent live project/global limits. Keep them disabled while their actual values are still loading so users cannot edit a blank fallback and accidentally overwrite the resolved limits.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "globalMaxConcurrent",
|
||||
label: t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent"),
|
||||
help: t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects. Default: 4."),
|
||||
min: 0,
|
||||
max: 10000,
|
||||
disabled: concurrencyLoading,
|
||||
}}
|
||||
value={globalMaxConcurrent ?? null}
|
||||
onChange={(v) => onGlobalMaxConcurrentChange(v ?? undefined)}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
export default SchedulingGlobalSection;
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Search entries for the Scheduling section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The `overlapIgnorePaths` repeating-row editor, the Global/This-project scope group headers, and the moved step-execution stub are deliberately absent — they are bespoke chrome or a repeating editor, not descriptor rows.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const schedulingSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "maxConcurrent",
|
||||
labelKey: "settings.scheduling.maxConcurrentTasks",
|
||||
labelFallback: "Max Concurrent Tasks",
|
||||
helpKey: "settings.scheduling.maxConcurrentTasksHint",
|
||||
helpFallback: "Default: 2.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
This row's help is just "Default: 2.", so the label is nearly all the index has to match on. The keywords carry the vocabulary the copy never spells out — an operator hunting for how many tasks run at once has no other way to land here.
|
||||
*/
|
||||
keywords: ["parallelism", "capacity", "how many tasks at once", "agents", "cap"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "maxConcurrentVerifications",
|
||||
labelKey: "settings.scheduling.maxConcurrentVerifications",
|
||||
labelFallback: "Max Concurrent Verifications",
|
||||
helpKey: "settings.scheduling.maxConcurrentVerificationsHint",
|
||||
helpFallback: "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8.",
|
||||
keywords: ["parallelism", "tests", "cpu", "load"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "maxTriageConcurrent",
|
||||
labelKey: "settings.scheduling.maxTriageConcurrent",
|
||||
labelFallback: "Max Triage Concurrent",
|
||||
helpKey: "settings.scheduling.maximumConcurrentPlanningAgents",
|
||||
helpFallback: "Maximum concurrent planning agents. Default: 2.",
|
||||
keywords: ["parallelism", "capacity", "spec"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "pollIntervalMs",
|
||||
labelKey: "settings.scheduling.pollIntervalMs",
|
||||
labelFallback: "Poll Interval (ms)",
|
||||
helpKey: "settings.scheduling.pollIntervalMsHint",
|
||||
helpFallback: "Default: 15000 (15 seconds).",
|
||||
keywords: ["tick", "engine loop", "frequency", "refresh"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "heartbeatScopeDiscipline",
|
||||
labelKey: "settings.scheduling.heartbeatScopeDiscipline",
|
||||
labelFallback: "Heartbeat Scope Discipline",
|
||||
helpKey: "settings.scheduling.strictCoordinationFocusedHigherPerTickTokensLite",
|
||||
helpFallback:
|
||||
"Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.",
|
||||
keywords: ["agent prompt", "token cost"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "engineerBacklogAutoClaim",
|
||||
labelKey: "settings.scheduling.letEngineerAgentsAutoClaimBacklogTasks",
|
||||
labelFallback: " Let engineer agents auto-claim backlog tasks ",
|
||||
helpKey: "settings.scheduling.backlogNoTaskAutoClaimIsExecutorOnly",
|
||||
helpFallback:
|
||||
"Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.",
|
||||
keywords: ["pick up work", "unassigned", "todo"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "taskStuckTimeoutMs",
|
||||
labelKey: "settings.scheduling.stuckTaskTimeoutMinutes",
|
||||
labelFallback: "Stuck Task Timeout (minutes)",
|
||||
helpKey: "settings.scheduling.timeoutInMinutesForDetectingStuckTasksWhen",
|
||||
helpFallback:
|
||||
"Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10. Default: 10 minutes (600000ms).",
|
||||
keywords: ["hung", "frozen", "watchdog", "kill"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "buildTimeoutMs",
|
||||
labelKey: "settings.scheduling.buildTimeoutMinutes",
|
||||
labelFallback: "Build/Verification Timeout (minutes)",
|
||||
helpKey: "settings.scheduling.maximumTimeInMinutesForBuildVerificationCommands",
|
||||
helpFallback:
|
||||
"Maximum time in minutes for build/verification commands before they are killed. Raise for large monorepo or Docker builds. Default: 5.",
|
||||
keywords: ["test timeout", "compile", "slow build"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "staleHighFanoutBlockerAgeThresholdMs",
|
||||
labelKey: "settings.scheduling.staleHighFanOutEscalationHours",
|
||||
labelFallback: "Stale High Fan-out Escalation (hours)",
|
||||
helpKey: "settings.scheduling.escalateHighFanOutBlockersOnlyAfterThey",
|
||||
helpFallback:
|
||||
"Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.",
|
||||
keywords: ["dependencies", "alert", "notify"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "preserveProgressOnStuckRequeue",
|
||||
labelKey: "settings.scheduling.preserveStepProgressOnStuckTaskRequeue",
|
||||
labelFallback: " Preserve step progress on stuck-task requeue ",
|
||||
helpKey: "settings.scheduling.whenTheStuckDetectorKillsAndReQueues",
|
||||
helpFallback:
|
||||
"When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled.",
|
||||
keywords: ["resume", "restart", "retry"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "specStalenessEnabled",
|
||||
labelKey: "settings.scheduling.enablePlanStalenessEnforcement",
|
||||
labelFallback: " Enable plan staleness enforcement ",
|
||||
helpKey: "settings.scheduling.whenEnabledTasksWithStalePlansPROMPTMd",
|
||||
helpFallback:
|
||||
"When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning. Default: disabled.",
|
||||
keywords: ["spec", "replan", "outdated"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "specStalenessMaxAgeMs",
|
||||
labelKey: "settings.scheduling.staleSpecThresholdHours",
|
||||
labelFallback: "Stale Spec Threshold (hours)",
|
||||
helpKey: "settings.scheduling.maximumAgeInHoursBeforeAPlanIs",
|
||||
helpFallback: "Maximum age in hours before a plan is considered stale. Default: 6 hours.",
|
||||
keywords: ["PROMPT.md", "replan", "age"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "autoArchiveDoneTasksEnabled",
|
||||
labelKey: "settings.scheduling.enableAutomaticTaskArchiving",
|
||||
labelFallback: " Enable automatic task archiving ",
|
||||
helpKey: "settings.scheduling.completedTasksOlderThanTheThresholdAreMoved",
|
||||
helpFallback:
|
||||
"Completed tasks older than the threshold are moved out of the active task database. Default: enabled.",
|
||||
keywords: ["done column", "cleanup", "prune", "board clutter"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "autoArchiveDoneAfterMs",
|
||||
labelKey: "settings.scheduling.archiveCompletedTasksAfterDays",
|
||||
labelFallback: "Archive Completed Tasks After (days)",
|
||||
helpKey: "settings.scheduling.numberOfDaysATaskCanStayIn",
|
||||
helpFallback:
|
||||
"Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).",
|
||||
keywords: ["retention", "cleanup", "age"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "archiveAgentLogMode",
|
||||
labelKey: "settings.scheduling.archiveAgentLog",
|
||||
labelFallback: "Archive Agent Log",
|
||||
helpKey: "settings.scheduling.compactModeKeepsArchiveSizeLowWhilePreserving",
|
||||
helpFallback:
|
||||
"Compact mode keeps archive size low while preserving recent agent activity for context. Default: compact.",
|
||||
keywords: ["history", "transcript", "disk space", "retention"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "autoArchiveDuplicateTasksEnabled",
|
||||
labelKey: "settings.scheduling.autoArchiveDuplicateTasks",
|
||||
labelFallback: " Automatically archive duplicate tasks ",
|
||||
helpKey: "settings.scheduling.autoArchiveDuplicateTasksHelp",
|
||||
helpFallback:
|
||||
"Automatically archive tasks detected as same-agent duplicates on creation (off by default). When disabled, duplicates are flagged in place with the yellow Duplicate chip and Keep/Archive actions instead of being archived automatically.",
|
||||
keywords: ["near duplicate", "dedupe", "repeat"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "maxStuckKills",
|
||||
labelKey: "settings.scheduling.maxStuckRetries",
|
||||
labelFallback: "Max Stuck Retries",
|
||||
helpKey: "settings.scheduling.maximumStuckDetectorRetriesBeforeATaskIs",
|
||||
helpFallback: "Maximum stuck-detector retries before a task is marked failed. Default: 6.",
|
||||
keywords: ["give up", "attempts", "hung"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "groupOverlappingFiles",
|
||||
labelKey: "settings.scheduling.serializeTasksWithOverlappingFiles",
|
||||
labelFallback: " Serialize tasks with overlapping files ",
|
||||
helpKey: "settings.scheduling.whenEnabledTasksThatModifyTheSameFiles",
|
||||
helpFallback:
|
||||
"When enabled, tasks that modify the same files are queued serially to avoid merge conflicts. Default: enabled.",
|
||||
keywords: ["file scope", "collision", "queue", "lease"],
|
||||
},
|
||||
{
|
||||
sectionId: "scheduling",
|
||||
key: "ignoreHiddenOverlapPaths",
|
||||
labelKey: "settings.scheduling.ignoreHiddenDotPathsInOverlapChecks",
|
||||
labelFallback: " Ignore hidden dot paths in overlap checks ",
|
||||
helpKey: "settings.scheduling.ignoreHiddenDotPathsHelp",
|
||||
helpFallback:
|
||||
"When enabled, overlap checks ignore hidden path segments such as .fusion/, .changeset/, .github/, .env, and nested .cache/ directories. Uncheck to restore legacy counting for stricter serialization. Default: enabled.",
|
||||
keywords: ["dotfiles", "collision", "file scope"],
|
||||
},
|
||||
];
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MovedSettingsStub } from "./MovedSettingsStub";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import type { SettingsFormState, SetSettingsForm } from "./context";
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
|
||||
export interface SchedulingSectionProps {
|
||||
scopeBanner: ReactNode;
|
||||
form: SettingsFormState;
|
||||
setForm: SetSettingsForm;
|
||||
globalMaxConcurrent: number | undefined;
|
||||
concurrencyLoading?: boolean;
|
||||
onGlobalMaxConcurrentChange: (value: number | undefined) => void;
|
||||
onOverlapIgnorePathChange: (index: number, value: string) => void;
|
||||
onOpenOverlapPathPicker: (index: number) => void;
|
||||
onRemoveOverlapIgnorePath: (index: number) => void;
|
||||
@@ -18,182 +17,231 @@ export interface SchedulingSectionProps {
|
||||
onOpenWorkflowSettings?: () => void;
|
||||
}
|
||||
/*
|
||||
FNXC:SettingsScopeGrouping 2026-06-25-10:42:
|
||||
Mobile settings must make clear which controls are global (all projects) vs project-scoped; group scheduling fields under labeled Global/This-project subheadings with a scope badge so operators don't mistake the global concurrency cap for a per-project setting.
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The plain label+control+help rows here render through the shared settings primitives instead of hand-rolled `form-group` + `checkbox-label` + `form-text text-muted` markup, so their labels, help copy, and padding come from the one settings type scale. `.form-group` itself stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath the rest of the dashboard.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-18:52:
|
||||
This section is single-scope: every key here is project-scoped (`DEFAULT_PROJECT_SETTINGS`) — concurrency, timeouts, staleness, archiving, and overlap policy all describe one project's scheduling posture.
|
||||
The machine-wide cap (`globalMaxConcurrent`) moved to SchedulingGlobalSection, and the `ScopeGroupHeader` chrome that used to separate the two authority levels went with it. Mixing scopes in one section meant the answer to "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.
|
||||
Rows keep their per-row `scope` badge even though the section is now uniformly project-scoped: search can land an operator on a single control with no section chrome in view, so the badge is the only scope signal at that moment.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The `overlapIgnorePaths` allowlist deliberately keeps its bespoke markup: it is a repeating row editor with per-row Browse/Remove buttons, and its help interleaves `t()` fragments with `<code>` elements, which a single-string descriptor `help` cannot express.
|
||||
*/
|
||||
interface ScopeGroupHeaderProps {
|
||||
title: string;
|
||||
caption: string;
|
||||
badgeLabel: string;
|
||||
scope: "global" | "project";
|
||||
}
|
||||
function ScopeGroupHeader({ title, caption, badgeLabel, scope }: ScopeGroupHeaderProps) {
|
||||
return (<div className="settings-scope-group">
|
||||
<div className="settings-scope-group-header">
|
||||
<h5 className="settings-section-heading">{title}</h5>
|
||||
<span className={`settings-scope-badge settings-scope-badge--${scope}`}>{badgeLabel}</span>
|
||||
</div>
|
||||
<small className="settings-scope-caption">{caption}</small>
|
||||
</div>);
|
||||
}
|
||||
export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurrent, concurrencyLoading = false, onGlobalMaxConcurrentChange, onOverlapIgnorePathChange, onOpenOverlapPathPicker, onRemoveOverlapIgnorePath, onAddOverlapIgnorePath, onOpenWorkflowSettings, }: SchedulingSectionProps) {
|
||||
export function SchedulingSection({ form, setForm, concurrencyLoading = false, onOverlapIgnorePathChange, onOpenOverlapPathPicker, onRemoveOverlapIgnorePath, onAddOverlapIgnorePath, onOpenWorkflowSettings, }: SchedulingSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.scheduling.scheduling", "Scheduling")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsConcurrency 2026-06-22-20:18:
|
||||
Concurrency inputs represent live project/global limits. Keep them disabled while their actual values are still loading so users cannot edit a blank fallback and accidentally overwrite the resolved limits.
|
||||
*/}
|
||||
<ScopeGroupHeader scope="global" title={t("settings.scheduling.scopeGlobalTitle", "Global — applies to all projects")} caption={t("settings.scheduling.scopeGlobalCaption", "Shared by every project on this machine.")} badgeLabel={t("settings.scheduling.scopeBadgeGlobal", "Global")}/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalMaxConcurrent">{t("settings.scheduling.globalMaxConcurrent", "Global Max Concurrent")}</label>
|
||||
<input id="globalMaxConcurrent" type="number" min={0} max={10000} disabled={concurrencyLoading} value={globalMaxConcurrent ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onGlobalMaxConcurrentChange(val === "" ? undefined : Number(val));
|
||||
}}/>
|
||||
<small className="form-text text-muted">{t("settings.scheduling.maximumConcurrentAgentsAcrossAllProjects", "Maximum concurrent agents across all projects. Default: 4.")}</small>
|
||||
</div>
|
||||
<div className="settings-section-divider"/>
|
||||
<ScopeGroupHeader scope="project" title={t("settings.scheduling.scopeProjectTitle", "This project")} caption={t("settings.scheduling.scopeProjectCaption", "Only affects the currently selected project.")} badgeLabel={t("settings.scheduling.scopeBadgeProject", "Project")}/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxConcurrent">{t("settings.scheduling.maxConcurrentTasks", "Max Concurrent Tasks")}</label>
|
||||
<input id="maxConcurrent" type="number" min={1} max={10} disabled={concurrencyLoading} value={form.maxConcurrent ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.maxConcurrentTasksHint", "Default: 2.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxConcurrentVerifications">{t("settings.scheduling.maxConcurrentVerifications", "Max Concurrent Verifications")}</label>
|
||||
<input id="maxConcurrentVerifications" type="number" min={1} max={8} disabled={concurrencyLoading} value={form.maxConcurrentVerifications ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "maxConcurrent",
|
||||
label: t("settings.scheduling.maxConcurrentTasks", "Max Concurrent Tasks"),
|
||||
help: t("settings.scheduling.maxConcurrentTasksHint", "Default: 2."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
max: 10,
|
||||
disabled: concurrencyLoading,
|
||||
}}
|
||||
value={form.maxConcurrent ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, maxConcurrent: v ?? undefined } as SettingsFormState))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "maxConcurrentVerifications",
|
||||
label: t("settings.scheduling.maxConcurrentVerifications", "Max Concurrent Verifications"),
|
||||
help: t("settings.scheduling.maxConcurrentVerificationsHint", "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
max: 8,
|
||||
disabled: concurrencyLoading,
|
||||
}}
|
||||
value={form.maxConcurrentVerifications ?? null}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
setForm((f) => ({ ...f, maxConcurrentVerifications: undefined } as SettingsFormState));
|
||||
return;
|
||||
}
|
||||
// FNXC:VerificationConcurrency 2026-07-15-08:20: Clamp to 1–8 on the form path so UI cannot persist values outside the engine hard cap.
|
||||
const n = Math.min(8, Math.max(1, Math.floor(Number(val)) || 1));
|
||||
const n = Math.min(8, Math.max(1, Math.floor(v) || 1));
|
||||
setForm((f) => ({ ...f, maxConcurrentVerifications: n } as SettingsFormState));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.maxConcurrentVerificationsHint", "Caps stacked typecheck/build verification across tasks. Default: 1. Range: 1–8.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxTriageConcurrent">{t("settings.scheduling.maxTriageConcurrent", "Max Triage Concurrent")}</label>
|
||||
<input id="maxTriageConcurrent" type="number" min={1} max={10} disabled={concurrencyLoading} value={form.maxTriageConcurrent ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, maxTriageConcurrent: val === "" ? undefined : Number(val) } as SettingsFormState));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.maximumConcurrentPlanningAgents", "Maximum concurrent planning agents. Default: 2.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pollIntervalMs">{t("settings.scheduling.pollIntervalMs", "Poll Interval (ms)")}</label>
|
||||
<input id="pollIntervalMs" type="number" min={5000} step={1000} value={form.pollIntervalMs ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as SettingsFormState));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.pollIntervalMsHint", "Default: 15000 (15 seconds).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="heartbeatScopeDiscipline">{t("settings.scheduling.heartbeatScopeDiscipline", "Heartbeat Scope Discipline")}</label>
|
||||
<select id="heartbeatScopeDiscipline" className="select" value={form.heartbeatScopeDiscipline ?? "strict"} onChange={(e) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
heartbeatScopeDiscipline: e.target.value as "strict" | "lite" | "off",
|
||||
}));
|
||||
}}>
|
||||
<option value="strict">{t("settings.scheduling.strictDefault", "Strict (default)")}</option>
|
||||
<option value="lite">{t("settings.scheduling.lite", "Lite")}</option>
|
||||
<option value="off">{t("settings.scheduling.off", "Off")}</option>
|
||||
</select>
|
||||
<small>{t("settings.scheduling.strictCoordinationFocusedHigherPerTickTokensLite", "Strict \u2014 coordination-focused; higher per-tick tokens. Lite \u2014 pre-2026-05-11 behavior. Off \u2014 minimal procedure.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="engineerBacklogAutoClaim" className="checkbox-label">
|
||||
<input id="engineerBacklogAutoClaim" type="checkbox" checked={form.engineerBacklogAutoClaim === true} onChange={(e) => setForm((f) => ({ ...f, engineerBacklogAutoClaim: e.target.checked }))}/>{t("settings.scheduling.letEngineerAgentsAutoClaimBacklogTasks", " Let engineer agents auto-claim backlog tasks ")}</label>
|
||||
<small>{t("settings.scheduling.backlogNoTaskAutoClaimIsExecutorOnly", "Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="taskStuckTimeoutMs">{t("settings.scheduling.stuckTaskTimeoutMinutes", "Stuck Task Timeout (minutes)")}</label>
|
||||
<input id="taskStuckTimeoutMs" type="number" min={1} step={1} value={form.taskStuckTimeoutMs ? Math.round(form.taskStuckTimeoutMs / 60000) : ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.timeoutInMinutesForDetectingStuckTasksWhen", "Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10. Default: 10 minutes (600000ms).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="buildTimeoutMs">{t("settings.scheduling.buildTimeoutMinutes", "Build/Verification Timeout (minutes)")}</label>
|
||||
<input id="buildTimeoutMs" type="number" min={1} step={1} value={form.buildTimeoutMs ? Math.round(form.buildTimeoutMs / 60000) : ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({ ...f, buildTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.maximumTimeInMinutesForBuildVerificationCommands", "Maximum time in minutes for build/verification commands before they are killed. Raise for large monorepo or Docker builds. Default: 5.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">{t("settings.scheduling.staleHighFanOutEscalationHours", "Stale High Fan-out Escalation (hours)")}</label>
|
||||
<input id="staleHighFanoutBlockerAgeThresholdMs" type="number" min={1} step={1} value={form.staleHighFanoutBlockerAgeThresholdMs ? Math.round(form.staleHighFanoutBlockerAgeThresholdMs / 3600000) : ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined,
|
||||
}));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.escalateHighFanOutBlockersOnlyAfterThey", "Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="preserveProgressOnStuckRequeue" className="checkbox-label">
|
||||
<input id="preserveProgressOnStuckRequeue" type="checkbox" checked={form.preserveProgressOnStuckRequeue !== false} onChange={(e) => setForm((f) => ({ ...f, preserveProgressOnStuckRequeue: e.target.checked }))}/>{t("settings.scheduling.preserveStepProgressOnStuckTaskRequeue", " Preserve step progress on stuck-task requeue ")}</label>
|
||||
<small>{t("settings.scheduling.whenTheStuckDetectorKillsAndReQueues", "When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="specStalenessEnabled" className="checkbox-label">
|
||||
<input id="specStalenessEnabled" type="checkbox" checked={form.specStalenessEnabled || false} onChange={(e) => setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))}/>{t("settings.scheduling.enablePlanStalenessEnforcement", " Enable plan staleness enforcement ")}</label>
|
||||
<small>{t("settings.scheduling.whenEnabledTasksWithStalePlansPROMPTMd", "When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning. Default: disabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="specStalenessMaxAgeMs">{t("settings.scheduling.staleSpecThresholdHours", "Stale Spec Threshold (hours)")}</label>
|
||||
<input id="specStalenessMaxAgeMs" type="number" min={0} step={1} value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined }));
|
||||
}} disabled={!form.specStalenessEnabled}/>
|
||||
<small>{t("settings.scheduling.maximumAgeInHoursBeforeAPlanIs", "Maximum age in hours before a plan is considered stale. Default: 6 hours.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoArchiveDoneTasksEnabled" className="checkbox-label">
|
||||
<input id="autoArchiveDoneTasksEnabled" type="checkbox" checked={form.autoArchiveDoneTasksEnabled ?? true} onChange={(e) => setForm((f) => ({
|
||||
}}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "maxTriageConcurrent",
|
||||
label: t("settings.scheduling.maxTriageConcurrent", "Max Triage Concurrent"),
|
||||
help: t("settings.scheduling.maximumConcurrentPlanningAgents", "Maximum concurrent planning agents. Default: 2."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
max: 10,
|
||||
disabled: concurrencyLoading,
|
||||
}}
|
||||
value={form.maxTriageConcurrent ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, maxTriageConcurrent: v ?? undefined } as SettingsFormState))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "pollIntervalMs",
|
||||
label: t("settings.scheduling.pollIntervalMs", "Poll Interval (ms)"),
|
||||
help: t("settings.scheduling.pollIntervalMsHint", "Default: 15000 (15 seconds)."),
|
||||
scope: "project",
|
||||
min: 5000,
|
||||
step: 1000,
|
||||
}}
|
||||
value={form.pollIntervalMs ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, pollIntervalMs: v ?? undefined } as SettingsFormState))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "heartbeatScopeDiscipline",
|
||||
label: t("settings.scheduling.heartbeatScopeDiscipline", "Heartbeat Scope Discipline"),
|
||||
help: t("settings.scheduling.strictCoordinationFocusedHigherPerTickTokensLite", "Strict \u2014 coordination-focused; higher per-tick tokens. Lite \u2014 pre-2026-05-11 behavior. Off \u2014 minimal procedure."),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "strict", label: t("settings.scheduling.strictDefault", "Strict (default)") },
|
||||
{ value: "lite", label: t("settings.scheduling.lite", "Lite") },
|
||||
{ value: "off", label: t("settings.scheduling.off", "Off") },
|
||||
],
|
||||
}}
|
||||
value={form.heartbeatScopeDiscipline ?? "strict"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
autoArchiveDoneTasksEnabled: e.target.checked,
|
||||
}))}/>{t("settings.scheduling.enableAutomaticTaskArchiving", " Enable automatic task archiving ")}</label>
|
||||
<small>{t("settings.scheduling.completedTasksOlderThanTheThresholdAreMoved", "Completed tasks older than the threshold are moved out of the active task database. Default: enabled.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoArchiveDoneAfterMs">{t("settings.scheduling.archiveCompletedTasksAfterDays", "Archive Completed Tasks After (days)")}</label>
|
||||
<input id="autoArchiveDoneAfterMs" type="number" min={1} step={1} value={form.autoArchiveDoneAfterMs !== undefined ? Math.round(form.autoArchiveDoneAfterMs / MS_PER_DAY) : AUTO_ARCHIVE_DEFAULT_AFTER_DAYS} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
autoArchiveDoneAfterMs: val === "" ? undefined : num * MS_PER_DAY,
|
||||
}));
|
||||
}} disabled={form.autoArchiveDoneTasksEnabled === false}/>
|
||||
<small>{t("settings.scheduling.numberOfDaysATaskCanStayIn", "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="archiveAgentLogMode">{t("settings.scheduling.archiveAgentLog", "Archive Agent Log")}</label>
|
||||
<select id="archiveAgentLogMode" value={form.archiveAgentLogMode ?? "compact"} onChange={(e) => setForm((f) => ({
|
||||
heartbeatScopeDiscipline: v as "strict" | "lite" | "off",
|
||||
}))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "engineerBacklogAutoClaim",
|
||||
label: t("settings.scheduling.letEngineerAgentsAutoClaimBacklogTasks", " Let engineer agents auto-claim backlog tasks "),
|
||||
help: t("settings.scheduling.backlogNoTaskAutoClaimIsExecutorOnly", "Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.engineerBacklogAutoClaim === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, engineerBacklogAutoClaim: v === true }))}
|
||||
/>
|
||||
{/* FNXC:SettingsScheduling 2026-07-15-17:35: Minutes are a display unit only — the setting persists milliseconds. A non-positive or emptied value stores `undefined` (not 0), so the key is absent from the settings blob and the stuck detector falls back to its schema default rather than treating every task as instantly stuck. */}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "taskStuckTimeoutMs",
|
||||
label: t("settings.scheduling.stuckTaskTimeoutMinutes", "Stuck Task Timeout (minutes)"),
|
||||
help: t("settings.scheduling.timeoutInMinutesForDetectingStuckTasksWhen", "Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10. Default: 10 minutes (600000ms)."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
step: 1,
|
||||
}}
|
||||
value={form.taskStuckTimeoutMs ? Math.round(form.taskStuckTimeoutMs / 60000) : null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, taskStuckTimeoutMs: v !== null && v > 0 ? v * 60000 : undefined }))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "buildTimeoutMs",
|
||||
label: t("settings.scheduling.buildTimeoutMinutes", "Build/Verification Timeout (minutes)"),
|
||||
help: t("settings.scheduling.maximumTimeInMinutesForBuildVerificationCommands", "Maximum time in minutes for build/verification commands before they are killed. Raise for large monorepo or Docker builds. Default: 5."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
step: 1,
|
||||
}}
|
||||
value={form.buildTimeoutMs ? Math.round(form.buildTimeoutMs / 60000) : null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, buildTimeoutMs: v !== null && v > 0 ? v * 60000 : undefined }))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "staleHighFanoutBlockerAgeThresholdMs",
|
||||
label: t("settings.scheduling.staleHighFanOutEscalationHours", "Stale High Fan-out Escalation (hours)"),
|
||||
help: t("settings.scheduling.escalateHighFanOutBlockersOnlyAfterThey", "Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
step: 1,
|
||||
}}
|
||||
value={form.staleHighFanoutBlockerAgeThresholdMs ? Math.round(form.staleHighFanoutBlockerAgeThresholdMs / 3600000) : null}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
archiveAgentLogMode: e.target.value as "none" | "compact" | "full",
|
||||
}))} disabled={form.autoArchiveDoneTasksEnabled === false}>
|
||||
<option value="compact">{t("settings.scheduling.compactSummaryAndRecentEntries", "Compact summary and recent entries")}</option>
|
||||
<option value="none">{t("settings.scheduling.doNotArchiveAgentLogs", "Do not archive agent logs")}</option>
|
||||
<option value="full">{t("settings.scheduling.fullAgentLog", "Full agent log")}</option>
|
||||
</select>
|
||||
<small>{t("settings.scheduling.compactModeKeepsArchiveSizeLowWhilePreserving", "Compact mode keeps archive size low while preserving recent agent activity for context. Default: compact.")}</small>
|
||||
</div>
|
||||
staleHighFanoutBlockerAgeThresholdMs: v !== null && v > 0 ? v * 3600000 : undefined,
|
||||
}))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "preserveProgressOnStuckRequeue",
|
||||
label: t("settings.scheduling.preserveStepProgressOnStuckTaskRequeue", " Preserve step progress on stuck-task requeue "),
|
||||
help: t("settings.scheduling.whenTheStuckDetectorKillsAndReQueues", "When the stuck detector kills and re-queues a task, keep completed step statuses so the agent can resume from where it left off. Disable to reset every step to pending on each stuck retry. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.preserveProgressOnStuckRequeue !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, preserveProgressOnStuckRequeue: v === true }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "specStalenessEnabled",
|
||||
label: t("settings.scheduling.enablePlanStalenessEnforcement", " Enable plan staleness enforcement "),
|
||||
help: t("settings.scheduling.whenEnabledTasksWithStalePlansPROMPTMd", "When enabled, tasks with stale plans (PROMPT.md older than the threshold) are automatically sent back to planning for replanning. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.specStalenessEnabled || false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, specStalenessEnabled: v === true }))}
|
||||
/>
|
||||
{/* FNXC:SettingsScheduling 2026-07-15-17:35: The threshold is gated on its own enforcement toggle and disabled rather than hidden, so an operator turning staleness on can see the age that will take effect. Unlike the timeouts above, an explicit 0 is preserved (immediate staleness) — only an emptied field stores `undefined`. */}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "specStalenessMaxAgeMs",
|
||||
label: t("settings.scheduling.staleSpecThresholdHours", "Stale Spec Threshold (hours)"),
|
||||
help: t("settings.scheduling.maximumAgeInHoursBeforeAPlanIs", "Maximum age in hours before a plan is considered stale. Default: 6 hours."),
|
||||
scope: "project",
|
||||
min: 0,
|
||||
step: 1,
|
||||
disabled: !form.specStalenessEnabled,
|
||||
}}
|
||||
value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, specStalenessMaxAgeMs: v !== null ? v * 3600000 : undefined }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "autoArchiveDoneTasksEnabled",
|
||||
label: t("settings.scheduling.enableAutomaticTaskArchiving", " Enable automatic task archiving "),
|
||||
help: t("settings.scheduling.completedTasksOlderThanTheThresholdAreMoved", "Completed tasks older than the threshold are moved out of the active task database. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.autoArchiveDoneTasksEnabled ?? true}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
autoArchiveDoneTasksEnabled: v === true,
|
||||
}))}
|
||||
/>
|
||||
{/* FNXC:SettingsScheduling 2026-07-15-17:35: The threshold and log mode are gated on the archiving toggle and disabled rather than hidden, so an operator turning archiving on can see the values that will take effect. An unset threshold displays the schema default (2 days) rather than an empty field, because archiving is on by default and a blank box would misread as "never". */}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "autoArchiveDoneAfterMs",
|
||||
label: t("settings.scheduling.archiveCompletedTasksAfterDays", "Archive Completed Tasks After (days)"),
|
||||
help: t("settings.scheduling.numberOfDaysATaskCanStayIn", "Number of days a task can stay in Done before it is archived. Default: 2 days (48 hours)."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
step: 1,
|
||||
disabled: form.autoArchiveDoneTasksEnabled === false,
|
||||
}}
|
||||
value={form.autoArchiveDoneAfterMs !== undefined ? Math.round(form.autoArchiveDoneAfterMs / MS_PER_DAY) : AUTO_ARCHIVE_DEFAULT_AFTER_DAYS}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
autoArchiveDoneAfterMs: v === null ? undefined : v * MS_PER_DAY,
|
||||
}))}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "archiveAgentLogMode",
|
||||
label: t("settings.scheduling.archiveAgentLog", "Archive Agent Log"),
|
||||
help: t("settings.scheduling.compactModeKeepsArchiveSizeLowWhilePreserving", "Compact mode keeps archive size low while preserving recent agent activity for context. Default: compact."),
|
||||
scope: "project",
|
||||
disabled: form.autoArchiveDoneTasksEnabled === false,
|
||||
options: [
|
||||
{ value: "compact", label: t("settings.scheduling.compactSummaryAndRecentEntries", "Compact summary and recent entries") },
|
||||
{ value: "none", label: t("settings.scheduling.doNotArchiveAgentLogs", "Do not archive agent logs") },
|
||||
{ value: "full", label: t("settings.scheduling.fullAgentLog", "Full agent log") },
|
||||
],
|
||||
}}
|
||||
value={form.archiveAgentLogMode ?? "compact"}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
archiveAgentLogMode: v as "none" | "compact" | "full",
|
||||
}))}
|
||||
/>
|
||||
{/**
|
||||
* FNXC:DuplicateIntake 2026-07-07-00:00 (FN-7658):
|
||||
* Operators do not want same-agent duplicate tasks (FN-4892 intake heuristic)
|
||||
@@ -201,38 +249,56 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
|
||||
* via the near-duplicate flag/UI. Default off; this toggle restores the old
|
||||
* aggressive auto-archive behavior when enabled.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoArchiveDuplicateTasksEnabled" className="checkbox-label">
|
||||
<input id="autoArchiveDuplicateTasksEnabled" type="checkbox" checked={form.autoArchiveDuplicateTasksEnabled ?? false} onChange={(e) => setForm((f) => ({
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "autoArchiveDuplicateTasksEnabled",
|
||||
label: t("settings.scheduling.autoArchiveDuplicateTasks", " Automatically archive duplicate tasks "),
|
||||
help: t("settings.scheduling.autoArchiveDuplicateTasksHelp", "Automatically archive tasks detected as same-agent duplicates on creation (off by default). When disabled, duplicates are flagged in place with the yellow Duplicate chip and Keep/Archive actions instead of being archived automatically."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.autoArchiveDuplicateTasksEnabled ?? false}
|
||||
onChange={(v) => setForm((f) => ({
|
||||
...f,
|
||||
autoArchiveDuplicateTasksEnabled: e.target.checked,
|
||||
}))}/>{t("settings.scheduling.autoArchiveDuplicateTasks", " Automatically archive duplicate tasks ")}</label>
|
||||
<small>{t("settings.scheduling.autoArchiveDuplicateTasksHelp", "Automatically archive tasks detected as same-agent duplicates on creation (off by default). When disabled, duplicates are flagged in place with the yellow Duplicate chip and Keep/Archive actions instead of being archived automatically.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxStuckKills">{t("settings.scheduling.maxStuckRetries", "Max Stuck Retries")}</label>
|
||||
<input id="maxStuckKills" type="number" min={1} step={1} value={form.maxStuckKills ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined }));
|
||||
}}/>
|
||||
<small>{t("settings.scheduling.maximumStuckDetectorRetriesBeforeATaskIs", "Maximum stuck-detector retries before a task is marked failed. Default: 6.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="groupOverlappingFiles" className="checkbox-label">
|
||||
<input id="groupOverlappingFiles" type="checkbox" checked={form.groupOverlappingFiles} onChange={(e) => setForm((f) => ({ ...f, groupOverlappingFiles: e.target.checked }))}/>{t("settings.scheduling.serializeTasksWithOverlappingFiles", " Serialize tasks with overlapping files ")}</label>
|
||||
<small>{t("settings.scheduling.whenEnabledTasksThatModifyTheSameFiles", "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts. Default: enabled.")}</small>
|
||||
</div>
|
||||
autoArchiveDuplicateTasksEnabled: v === true,
|
||||
}))}
|
||||
/>
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "maxStuckKills",
|
||||
label: t("settings.scheduling.maxStuckRetries", "Max Stuck Retries"),
|
||||
help: t("settings.scheduling.maximumStuckDetectorRetriesBeforeATaskIs", "Maximum stuck-detector retries before a task is marked failed. Default: 6."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
step: 1,
|
||||
}}
|
||||
value={form.maxStuckKills ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, maxStuckKills: v !== null && v > 0 ? v : undefined }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "groupOverlappingFiles",
|
||||
label: t("settings.scheduling.serializeTasksWithOverlappingFiles", " Serialize tasks with overlapping files "),
|
||||
help: t("settings.scheduling.whenEnabledTasksThatModifyTheSameFiles", "When enabled, tasks that modify the same files are queued serially to avoid merge conflicts. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.groupOverlappingFiles}
|
||||
onChange={(v) => setForm((f) => ({ ...f, groupOverlappingFiles: v === true }))}
|
||||
/>
|
||||
|
||||
{/**
|
||||
* FNXC:SettingsScheduling 2026-06-23-13:22:
|
||||
* Operators need a Scheduling toggle that defaults on for ignoring hidden dot paths in overlap checks while preserving the selected value independently of overlap serialization being enabled.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<label htmlFor="ignoreHiddenOverlapPaths" className="checkbox-label">
|
||||
<input id="ignoreHiddenOverlapPaths" type="checkbox" checked={form.ignoreHiddenOverlapPaths !== false} onChange={(e) => setForm((f) => ({ ...f, ignoreHiddenOverlapPaths: e.target.checked }))}/>{t("settings.scheduling.ignoreHiddenDotPathsInOverlapChecks", " Ignore hidden dot paths in overlap checks ")}</label>
|
||||
<small>{t("settings.scheduling.ignoreHiddenDotPathsHelp", "When enabled, overlap checks ignore hidden path segments such as .fusion/, .changeset/, .github/, .env, and nested .cache/ directories. Uncheck to restore legacy counting for stricter serialization. Default: enabled.")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "ignoreHiddenOverlapPaths",
|
||||
label: t("settings.scheduling.ignoreHiddenDotPathsInOverlapChecks", " Ignore hidden dot paths in overlap checks "),
|
||||
help: t("settings.scheduling.ignoreHiddenDotPathsHelp", "When enabled, overlap checks ignore hidden path segments such as .fusion/, .changeset/, .github/, .env, and nested .cache/ directories. Uncheck to restore legacy counting for stricter serialization. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.ignoreHiddenOverlapPaths !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ignoreHiddenOverlapPaths: v === true }))}
|
||||
/>
|
||||
|
||||
<div className="form-group settings-overlap-ignore-group">
|
||||
<label>{t("settings.scheduling.ignoredOverlapPaths", "Ignored overlap paths")}</label>
|
||||
@@ -241,7 +307,12 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
|
||||
<div className="settings-overlap-ignore-list">
|
||||
{(form.overlapIgnorePaths && form.overlapIgnorePaths.length > 0 ? form.overlapIgnorePaths : [""]).map((path, index) => (<div key={`overlap-ignore-${index}`} className="settings-overlap-ignore-row">
|
||||
<div className="settings-overlap-ignore-path-controls">
|
||||
<input type="text" value={path} placeholder={t("settings.scheduling.docs", "docs/")} onChange={(e) => onOverlapIgnorePathChange(index, e.target.value)}/>
|
||||
{/*
|
||||
FNXC:SettingsStyling 2026-07-15-18:52:
|
||||
Carries `.input` even though this row stays bespoke. Without a class it was a bare input inside `.form-group`, which styles its children `8px 12px` at 14px — while every other settings control (`.input`, and the shared row primitives that now name it) renders `6px 10px` at 13px. It was the one visibly mismatched text field left in Settings.
|
||||
The global `.form-group input` rule is deliberately not touched: 35 non-settings files depend on it. Naming the standard class on this input is the settings-local fix.
|
||||
*/}
|
||||
<input className="input" type="text" value={path} placeholder={t("settings.scheduling.docs", "docs/")} onChange={(e) => onOverlapIgnorePathChange(index, e.target.value)}/>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onOpenOverlapPathPicker(index)} aria-label={`Browse path for ignored overlap entry ${index + 1}`}>{t("settings.scheduling.browse", " Browse ")}</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onRemoveOverlapIgnorePath(index)} disabled={(form.overlapIgnorePaths ?? []).length === 0 && index === 0}>{t("settings.scheduling.remove", " Remove ")}</button>
|
||||
|
||||
@@ -5,21 +5,18 @@
|
||||
* no modal form state — the shell owns persistence; this section only titles and
|
||||
* mounts the relocated card (mirrors the RuntimesSections convention).
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecretsView } from "../../SecretsView";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
|
||||
export interface SecretsSectionProps {
|
||||
scopeBanner: ReactNode;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function SecretsSection({ scopeBanner, addToast }: SecretsSectionProps) {
|
||||
export function SecretsSection({ addToast }: SecretsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.nav.secrets", "Secrets")}</h4>
|
||||
<SecretsView addToast={addToast} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Search entries for the Source Control · Global section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-20:30:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* These rows carry the same `key`s as their project counterparts in SourceControlSection.search.ts — they are the same dual-scope settings at the other tier, and `sectionId` is what keeps them distinct (the index's uniqueness rule is per section+key). Their labels are the operator-facing discriminator: every one reads "Global …".
|
||||
* The global default tracking repo is absent: TrackingRepoSelect is a bespoke widget with no descriptor `key`, so it has no `data-settings-key` anchor to scroll to. It stays reachable via the section's `searchableText` in SETTINGS_SECTIONS.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const sourceControlGlobalSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "source-control-global",
|
||||
key: "gitlabInstanceUrl",
|
||||
labelKey: "settings.globalGeneral.gitLabInstanceUrl",
|
||||
labelFallback: "Global GitLab instance URL",
|
||||
helpKey: "settings.globalGeneral.gitLabInstanceUrlHint",
|
||||
helpFallback:
|
||||
"Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value. No default — unset.",
|
||||
keywords: ["self managed", "self-hosted", "fallback", "inherit"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control-global",
|
||||
key: "gitlabApiBaseUrl",
|
||||
labelKey: "settings.globalGeneral.gitLabApiBaseUrlOptional",
|
||||
labelFallback: "Global GitLab API base URL (optional / advanced)",
|
||||
helpKey: "settings.globalGeneral.gitLabApiBaseUrlHint",
|
||||
helpFallback:
|
||||
"Blank derives <instance>/api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL. No default — unset.",
|
||||
keywords: ["api v4", "gateway", "fallback", "self managed"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control-global",
|
||||
key: "gitlabAuthTokenType",
|
||||
labelKey: "settings.globalGeneral.gitLabTokenType",
|
||||
labelFallback: "Global GitLab token type",
|
||||
helpKey: "settings.globalGeneral.gitLabTokenTypeHint",
|
||||
helpFallback:
|
||||
"No default — unset (the selector falls back to personal access token until you choose otherwise).",
|
||||
keywords: ["personal access token", "project access token", "group access token", "pat"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control-global",
|
||||
key: "gitlabAuthToken",
|
||||
labelKey: "settings.globalGeneral.gitLabAccessToken",
|
||||
labelFallback: "Global GitLab access token",
|
||||
helpKey: "settings.globalGeneral.gitLabAuthTokenHint",
|
||||
helpFallback:
|
||||
"Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; write actions need api; project/group tokens remain limited by resource membership. No default — unset.",
|
||||
keywords: ["glpat", "private-token", "credentials", "secret", "fallback"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { GlobalSettings } from "@fusion/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
|
||||
type GlobalGitlabSettings = Pick<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType">;
|
||||
|
||||
export interface SourceControlGlobalSectionProps extends SectionBaseProps {
|
||||
globalSettings: GlobalGitlabSettings | null;
|
||||
onGlobalGitlabSettingsChange: (patch: Partial<GlobalGitlabSettings>) => void;
|
||||
globalTrackingRepoOptions: TrackingRepoOption[];
|
||||
globalTrackingRepoLoading: boolean;
|
||||
globalTrackingRepoError: string | null;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The global GitLab fallbacks and the global default tracking repo moved out of "General · Global" into their own section, adjacent to "Source Control · Project" under the Integrations nav group. They are integrations with GitHub/GitLab, not general app preferences, and pairing the two scopes is what lets an operator see a global fallback and its project override without hunting across unrelated sections.
|
||||
NOTE for future edits: `splitSettingsSave` (save-split.ts) gates these six dual-scope keys on the ACTIVE SECTION ID — they route to the global patch only while this section is open, and to the project patch everywhere else. That guard names `source-control-global` literally. Renaming this section id without updating save-split.ts would silently write these global fallbacks into project settings.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-20:30:
|
||||
No scope badge on any row here: all six keys (`gitlabEnabled`, `gitlabInstanceUrl`, `gitlabApiBaseUrl`, `gitlabAuthTokenType`, `gitlabAuthToken`, `githubTrackingDefaultRepo`) are declared in BOTH `DEFAULT_GLOBAL_SETTINGS` and `DEFAULT_PROJECT_SETTINGS`, so no badge can state their scope honestly. The section name ("Source Control · Global") carries it, as does each label's "Global …" prefix.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-20:30:
|
||||
Plain label+control+help rows use the shared primitives; `gitlabAuthToken` uses the primitive's `type: "password"` (defaulting `autocomplete="off"`). The tracking-repo select and the disclosure chrome stay bespoke — they are custom widgets, not label+control+help rows.
|
||||
*/
|
||||
export function SourceControlGlobalSection({ form, setForm, globalSettings, onGlobalGitlabSettingsChange, globalTrackingRepoOptions, globalTrackingRepoLoading, globalTrackingRepoError, }: SourceControlGlobalSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
/*
|
||||
FNXC:GitLabEnablement 2026-07-04-00:00:
|
||||
The GitLab rows read from the SCOPED global values (`globalSettings`), not the merged `form`, so a project override never renders as the global fallback's value. `form` is only the fallback while the scoped fetch is in flight.
|
||||
*/
|
||||
const globalGitlab = globalSettings ?? form;
|
||||
return (<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGithubTrackingDefaultRepo">{t("settings.globalGeneral.globalDefaultTrackingRepo", "Global default tracking repo")}</label>
|
||||
<TrackingRepoSelect id="globalGithubTrackingDefaultRepo" ariaLabel="Global default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={globalTrackingRepoOptions} loading={globalTrackingRepoLoading} error={globalTrackingRepoError ?? undefined} placeholder={t("settings.globalGeneral.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
|
||||
<small>{t("settings.globalGeneral.projectsInheritThisValueWhenTheyDoNot", "Projects inherit this value when they do not set a project default tracking repo. No default — unset.")}</small>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
FN-7453 adds a global GitLab enable fallback that can disable outbound GitLab HTTP API operations without deleting saved self-managed URL or token settings. Projects can override the enabled state when they need GitLab active while the global fallback is off.
|
||||
*/}
|
||||
<details className="settings-gitlab-disclosure" data-testid="global-gitlab-configuration-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.globalGeneral.gitLabConfiguration", "GitLab Configuration")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="globalGitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="globalGitlabEnabled" type="checkbox" checked={globalGitlab.gitlabEnabled !== false} onChange={(e) => onGlobalGitlabSettingsChange({ gitlabEnabled: e.target.checked })}/>
|
||||
{t("settings.globalGeneral.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{globalGitlab.gitlabEnabled === false ? t("settings.globalGeneral.gitLabDisabledHint", "GitLab API operations are disabled by global default. Saved URL and token fallbacks remain stored for re-enable.") : t("settings.globalGeneral.gitLabEnabledHint", "Global GitLab URL and token fallbacks apply to projects that do not set their own values. No default — unset (unset behaves as enabled until explicitly disabled).")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={globalGitlab.gitlabEnabled === false}>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "gitlabInstanceUrl",
|
||||
label: t("settings.globalGeneral.gitLabInstanceUrl", "Global GitLab instance URL"),
|
||||
help: t("settings.globalGeneral.gitLabInstanceUrlHint", "Blank defaults to GitLab.com. Projects inherit this self-managed GitLab URL unless they set their own project value. No default — unset."),
|
||||
type: "url",
|
||||
placeholder: "https://gitlab.com",
|
||||
disabled: globalGitlab.gitlabEnabled === false,
|
||||
}}
|
||||
value={globalGitlab.gitlabInstanceUrl ?? ""}
|
||||
onChange={(v) => onGlobalGitlabSettingsChange({ gitlabInstanceUrl: v || undefined })}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "gitlabApiBaseUrl",
|
||||
label: t("settings.globalGeneral.gitLabApiBaseUrlOptional", "Global GitLab API base URL (optional / advanced)"),
|
||||
help: t("settings.globalGeneral.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only for self-managed GitLab API gateways that use a different absolute http:// or https:// URL. No default — unset."),
|
||||
type: "url",
|
||||
placeholder: "https://gitlab.com/api/v4",
|
||||
disabled: globalGitlab.gitlabEnabled === false,
|
||||
}}
|
||||
value={globalGitlab.gitlabApiBaseUrl ?? ""}
|
||||
onChange={(v) => onGlobalGitlabSettingsChange({ gitlabApiBaseUrl: v || undefined })}
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "gitlabAuthTokenType",
|
||||
label: t("settings.globalGeneral.gitLabTokenType", "Global GitLab token type"),
|
||||
help: t("settings.globalGeneral.gitLabTokenTypeHint", "No default — unset (the selector falls back to personal access token until you choose otherwise)."),
|
||||
disabled: globalGitlab.gitlabEnabled === false,
|
||||
options: [
|
||||
{ value: "personal", label: t("settings.globalGeneral.gitLabPersonalAccessToken", "Personal access token") },
|
||||
{ value: "project", label: t("settings.globalGeneral.gitLabProjectAccessToken", "Project access token") },
|
||||
{ value: "group", label: t("settings.globalGeneral.gitLabGroupAccessToken", "Group access token") },
|
||||
],
|
||||
}}
|
||||
value={globalGitlab.gitlabAuthTokenType ?? "personal"}
|
||||
onChange={(v) => onGlobalGitlabSettingsChange({ gitlabAuthTokenType: v as "personal" | "project" | "group" })}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "gitlabAuthToken",
|
||||
label: t("settings.globalGeneral.gitLabAccessToken", "Global GitLab access token"),
|
||||
help: t("settings.globalGeneral.gitLabAuthTokenHint", "Projects inherit this fallback only when they do not set a project GitLab token. Read-only operations need read_api or api; write actions need api; project/group tokens remain limited by resource membership. No default — unset."),
|
||||
type: "password",
|
||||
disabled: globalGitlab.gitlabEnabled === false,
|
||||
}}
|
||||
value={globalGitlab.gitlabAuthToken ?? ""}
|
||||
onChange={(v) => onGlobalGitlabSettingsChange({ gitlabAuthToken: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</>);
|
||||
}
|
||||
export default SourceControlGlobalSection;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Search entries for the Source Control · Project section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-20:30:
|
||||
* These entries moved here with their controls from GeneralSection.search.ts and MergeSection.search.ts. An entry's `sectionId` must track the section that actually RENDERS the row — a stale id would surface the result, jump to a section that no longer holds the anchor, and do nothing.
|
||||
* The token rows are indexed for the first time: they were unindexable while they had to stay hand-rolled to avoid rendering a secret through a `type="text"` primitive, and SettingsTextRow's `type: "password"` support is what makes them addressable. The index stores their label and help copy only — never a value.
|
||||
* Still absent by design: the tracking-mode select and the tracking-repo select are bespoke widgets with no descriptor `key`, so they carry no `data-settings-key` anchor to scroll to. They stay reachable via the section's `searchableText` in SETTINGS_SECTIONS.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const sourceControlSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "githubLinkImportedIssuesToTracking",
|
||||
labelKey: "settings.general.alwaysLinkImportedGitHubIssuesToTracking",
|
||||
labelFallback: " Always link imported GitHub issues to GitHub tracking ",
|
||||
helpKey: "settings.general.whenEnabledImportedGitHubIssuesUseTheirSource",
|
||||
helpFallback:
|
||||
"When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled.",
|
||||
keywords: ["adopt issue", "import"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "githubTrackingDedupEnabled",
|
||||
labelKey: "settings.general.searchTheTrackingRepoForLikelyDuplicatesBefore",
|
||||
labelFallback: " Search the tracking repo for likely duplicates before opening a new issue ",
|
||||
helpKey: "settings.general.whenEnabledFusionChecksOpenAndClosedIssues",
|
||||
helpFallback:
|
||||
" When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. Default: enabled. ",
|
||||
keywords: ["dedupe", "deduplication"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "githubAuthMode",
|
||||
labelKey: "settings.merge.gitHubAuthMode",
|
||||
labelFallback: "GitHub auth mode",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
No helpKey/helpFallback: this row carries no help copy in the section either, and inventing index-only text would let search match words that appear nowhere on screen. The keywords carry the vocabulary instead.
|
||||
*/
|
||||
keywords: ["gh cli", "personal access token", "pat", "credentials", "login"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "githubAuthToken",
|
||||
labelKey: "settings.merge.gitHubPersonalAccessToken",
|
||||
labelFallback: "GitHub personal access token",
|
||||
helpKey: "settings.merge.githubAuthTokenHint",
|
||||
helpFallback: "No default — unset.",
|
||||
keywords: ["pat", "credentials", "secret", "ghp"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "gitlabInstanceUrl",
|
||||
labelKey: "settings.general.gitLabInstanceUrl",
|
||||
labelFallback: "GitLab instance URL",
|
||||
helpKey: "settings.general.gitLabInstanceUrlHint",
|
||||
helpFallback:
|
||||
"Blank uses GitLab.com or the global default. Set an absolute http:// or https:// URL for self-managed GitLab, such as https://gitlab.example.com/gitlab.",
|
||||
keywords: ["self managed", "self-hosted", "on premise"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "gitlabApiBaseUrl",
|
||||
labelKey: "settings.general.gitLabApiBaseUrlOptional",
|
||||
labelFallback: "GitLab API base URL (optional / advanced)",
|
||||
helpKey: "settings.general.gitLabApiBaseUrlHint",
|
||||
helpFallback:
|
||||
"Blank derives <instance>/api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL.",
|
||||
keywords: ["api v4", "gateway", "self managed"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "gitlabAuthTokenType",
|
||||
labelKey: "settings.merge.gitLabTokenType",
|
||||
labelFallback: "GitLab token type",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-20:30:
|
||||
No helpKey/helpFallback: the row carries none in the section either. Its enable/disable context comes from the disclosure hint above it, which belongs to no single row and so cannot be indexed as one row's help.
|
||||
*/
|
||||
keywords: ["personal access token", "project access token", "group access token", "pat"],
|
||||
},
|
||||
{
|
||||
sectionId: "source-control",
|
||||
key: "gitlabAuthToken",
|
||||
labelKey: "settings.merge.gitLabAccessToken",
|
||||
labelFallback: "GitLab access token",
|
||||
helpKey: "settings.merge.gitLabAuthTokenHint",
|
||||
helpFallback:
|
||||
"Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership. No default — unset.",
|
||||
keywords: ["glpat", "private-token", "credentials", "secret", "read_api"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import type { SectionBaseProps } from "./context";
|
||||
|
||||
export interface SourceControlSectionProps extends SectionBaseProps {
|
||||
projectTrackingRepoOptions: TrackingRepoOption[];
|
||||
projectTrackingRepoLoading: boolean;
|
||||
projectTrackingRepoError: string | null;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
Project source-control settings were split across two sections that neither named nor contained the topic: GitHub Tracking + the GitLab URL disclosure lived in "General · Project", while GitHub/GitLab authentication lived in "Merge". An operator wiring up GitLab had to visit both, and `gitlabEnabled` was writable from BOTH — two enable toggles for one key, so the last section saved won. This section is the single project-scoped home for GitHub/GitLab, which is what removes that duplicate rather than merely hiding it.
|
||||
The GitLab URL block and the GitLab auth block are now ONE disclosure with ONE `gitlabEnabled` toggle in its summary. Both blocks were already governed by that same key (FN-7453), so merging them costs no behavior — the two toggles were always writing the same setting.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-20:30:
|
||||
Rows on `gitlabEnabled`, `gitlabInstanceUrl`, `gitlabApiBaseUrl`, `gitlabAuthTokenType`, `gitlabAuthToken`, and `githubTrackingDefaultRepo` carry NO scope badge: every one of them is declared in BOTH `DEFAULT_GLOBAL_SETTINGS` and `DEFAULT_PROJECT_SETTINGS`, so a "project" badge would assert a scope the schema does not support. The section name ("Source Control · Project") carries the scope instead. The GitHub-only keys ARE project-only in the schema, so they keep their badge.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-20:30:
|
||||
Plain label+control+help rows render through the shared settings primitives. `githubAuthToken`/`gitlabAuthToken` use the primitive's `type: "password"` (which defaults `autocomplete="off"`) — previously these rows had to stay hand-rolled to avoid SettingsTextRow's hardcoded `type="text"` rendering a stored token in plain sight.
|
||||
Rows that stay bespoke: the tracking-mode select (its help is TWO blocks, the second conditional on unrelated model settings — a descriptor `help` is one string), the tracking-repo select (custom widget), and the disclosure chrome itself.
|
||||
|
||||
FNXC:SettingsHelp 2026-07-15-22:40:
|
||||
Staying off the primitive does NOT mean falling back to inline help. Both bespoke rows above render their copy through the same `SettingsHelpTip` as every migrated row, so the section shows one help idiom rather than a "?" on some rows and a paragraph on others. The tip hangs off the label line, so a custom control (or conditional, multi-part copy) is no obstacle — that is what its `ReactNode` children are for.
|
||||
*/
|
||||
export function SourceControlSection({ form, setForm, projectTrackingRepoOptions, projectTrackingRepoLoading, projectTrackingRepoError, }: SourceControlSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
<h4 className="settings-section-heading">{t("settings.general.gitHubTracking", "GitHub Tracking")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsHelp 2026-07-15-22:40:
|
||||
Both help strings ride in ONE tip rather than two inline `<small>`s. They are the same row's help — what the mode does, and how the issue title is derived — and the descriptor-based rows beside this one already carry a "?", so leaving these inline made the section render two idioms side by side.
|
||||
Two strings in one bubble (not two triggers) because an operator asking "what does this control do?" wants both answers at once; the second is a caveat on the first, not a separate topic.
|
||||
This is what `SettingsHelpTip`'s `ReactNode` children buy: the trailing fragment stays CONDITIONAL on the summarization settings, which a single-string descriptor `help` could not express — the reason this row is still hand-rolled.
|
||||
*/}
|
||||
<div className="form-group">
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="githubTrackingMode">{t("settings.general.defaultTrackingModeForNewTasks", "Default tracking mode for new tasks")}</label>
|
||||
<SettingsHelpTip settingKey="githubTrackingMode">
|
||||
{t("settings.general.controlsWhetherNewlyCreatedTasksHaveGitHubIssue", " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ")}
|
||||
{/*
|
||||
FNXC:SettingsGeneral 2026-06-22-03:20:
|
||||
Tracking-issue helper copy. The FN-6771 JSX→t() extraction left a raw HTML
|
||||
entity ("'") in this default string. As a t() argument the string is a
|
||||
plain JS value (not JSX-decoded), so the entity rendered verbatim as the
|
||||
literal "'" instead of an apostrophe. Use a real apostrophe so the copy
|
||||
reads correctly in both modal and embedded presentations.
|
||||
*/}
|
||||
{t("settings.general.trackingIssuesUseThisTaskAposSTitle", " Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. ")}
|
||||
{!form.autoSummarizeTitles && !form.useAiMergeCommitSummary && !form.githubTrackingEnabledByDefault
|
||||
? t("settings.general.enableSummarizationInProjectModelsToConfigureThatModel", " Enable summarization in Project Models to configure that model.")
|
||||
: ""}
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="githubTrackingMode" className="select" value={form.githubTrackingEnabledByDefault ? "new-tasks" : "off"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
githubTrackingEnabledByDefault: e.target.value === "new-tasks",
|
||||
}))}>
|
||||
<option value="off">{t("settings.general.offDefault", "Off (default)")}</option>
|
||||
<option value="new-tasks">{t("settings.general.onForNewTasks", "On for new tasks")}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
This checkbox is project-scoped and import-specific: operators can link imported GitHub issues to GitHub tracking without turning tracking on for every new task.
|
||||
*/}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "githubLinkImportedIssuesToTracking",
|
||||
label: t("settings.general.alwaysLinkImportedGitHubIssuesToTracking", " Always link imported GitHub issues to GitHub tracking "),
|
||||
help: t("settings.general.whenEnabledImportedGitHubIssuesUseTheirSource", "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.githubLinkImportedIssuesToTracking === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, githubLinkImportedIssuesToTracking: v === true }))}
|
||||
/>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-22:40: The row keeps its bespoke `TrackingRepoSelect` widget, but its help still reads like every neighbour's — the affordance belongs to the label line, not to the control, so a custom widget is no reason to fall back to an inline paragraph. */}
|
||||
<div className="form-group">
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">{t("settings.general.projectDefaultTrackingRepo", "Project default tracking repo")}</label>
|
||||
<SettingsHelpTip settingKey="projectGithubTrackingDefaultRepoGeneral">
|
||||
{t("settings.general.defaultRepoUsedWhenCreatingGitHubIssuesFor", "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.")}
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<TrackingRepoSelect id="projectGithubTrackingDefaultRepoGeneral" ariaLabel="Project default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={projectTrackingRepoOptions} loading={projectTrackingRepoLoading} error={projectTrackingRepoError ?? undefined} placeholder={t("settings.general.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "githubTrackingDedupEnabled",
|
||||
label: t("settings.general.searchTheTrackingRepoForLikelyDuplicatesBefore", " Search the tracking repo for likely duplicates before opening a new issue "),
|
||||
help: t("settings.general.whenEnabledFusionChecksOpenAndClosedIssues", " When enabled, Fusion checks open and closed issues in the target repo for likely duplicates (using File Scope paths and key symptoms) before creating a new tracking issue. Uncheck to always create a new issue. Default: enabled. "),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.githubTrackingDedupEnabled !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, githubTrackingDedupEnabled: v === true }))}
|
||||
/>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.merge.gitHubAuthentication", "GitHub Authentication")}</h4>
|
||||
{/* FNXC:SettingsStyling 2026-07-15-17:35: No `help` — this row carried no help copy before the migration, and inventing one would be new operator-facing text rather than a restyle. */}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "githubAuthMode",
|
||||
label: t("settings.merge.gitHubAuthMode", "GitHub auth mode"),
|
||||
scope: "project",
|
||||
options: [
|
||||
{ value: "gh-cli", label: t("settings.merge.gitHubCLIGhAuth", "GitHub CLI (gh auth) (default)") },
|
||||
{ value: "token", label: t("settings.merge.personalAccessToken", "Personal access token") },
|
||||
],
|
||||
}}
|
||||
value={form.githubAuthMode ?? "gh-cli"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, githubAuthMode: v as "gh-cli" | "token" }))}
|
||||
/>
|
||||
{(form.githubAuthMode ?? "gh-cli") === "token" && (
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "githubAuthToken",
|
||||
label: t("settings.merge.gitHubPersonalAccessToken", "GitHub personal access token"),
|
||||
help: t("settings.merge.githubAuthTokenHint", "No default — unset."),
|
||||
scope: "project",
|
||||
type: "password",
|
||||
}}
|
||||
value={form.githubAuthToken ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, githubAuthToken: v || undefined }))}
|
||||
/>
|
||||
)}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.general.gitLabConfiguration", "GitLab Configuration")}</h4>
|
||||
{/*
|
||||
FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
FN-7453 keeps saved GitLab URL settings separate from the active integration switch. The disclosure is collapsed by default to reduce Settings noise; the summary toggle remains reachable without expanding advanced self-managed URL fields.
|
||||
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
This is the ONLY `gitlabEnabled` control in the project scope. The Merge section rendered a second one (id `mergeGitlabEnabled`) governing the same key from a different screen; both are now this one toggle.
|
||||
*/}
|
||||
<details className="settings-gitlab-disclosure" data-testid="project-gitlab-configuration-disclosure">
|
||||
<summary>
|
||||
<span className="settings-gitlab-disclosure__title">{t("settings.general.gitLabConfiguration", "GitLab Configuration")}</span>
|
||||
<label className="checkbox-label settings-gitlab-disclosure__toggle" htmlFor="gitlabEnabled" onClick={(event) => event.stopPropagation()}>
|
||||
<input id="gitlabEnabled" type="checkbox" checked={form.gitlabEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, gitlabEnabled: e.target.checked }))}/>
|
||||
{t("settings.general.enableGitLabIntegration", "Enable GitLab integration")}
|
||||
</label>
|
||||
</summary>
|
||||
<small className="settings-description">{form.gitlabEnabled === false ? t("settings.general.gitLabDisabledHint", "GitLab API imports, comments, close/reopen, and refresh operations are disabled. Saved URLs and tokens remain stored for re-enable.") : t("settings.general.gitLabEnabledHint", "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com. No default — unset (unset behaves as enabled until explicitly disabled).")}</small>
|
||||
<div className="settings-gitlab-disclosure__body" aria-disabled={form.gitlabEnabled === false}>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "gitlabInstanceUrl",
|
||||
label: t("settings.general.gitLabInstanceUrl", "GitLab instance URL"),
|
||||
help: t("settings.general.gitLabInstanceUrlHint", "Blank uses GitLab.com or the global default. Set an absolute http:// or https:// URL for self-managed GitLab, such as https://gitlab.example.com/gitlab."),
|
||||
type: "url",
|
||||
placeholder: "https://gitlab.com",
|
||||
disabled: form.gitlabEnabled === false,
|
||||
}}
|
||||
value={form.gitlabInstanceUrl ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, gitlabInstanceUrl: v || undefined }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "gitlabApiBaseUrl",
|
||||
label: t("settings.general.gitLabApiBaseUrlOptional", "GitLab API base URL (optional / advanced)"),
|
||||
help: t("settings.general.gitLabApiBaseUrlHint", "Blank derives <instance>/api/v4. Override only when a self-managed GitLab API is served from a different absolute http:// or https:// URL."),
|
||||
type: "url",
|
||||
placeholder: "https://gitlab.com/api/v4",
|
||||
disabled: form.gitlabEnabled === false,
|
||||
}}
|
||||
value={form.gitlabApiBaseUrl ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, gitlabApiBaseUrl: v || undefined }))}
|
||||
/>
|
||||
{/*
|
||||
FNXC:GitLabEnablement 2026-07-02-00:00:
|
||||
FN-7453 makes project GitLab auth controls collapsible and governed by the same project-scoped enable switch as URL settings. Disabling GitLab preserves saved tokens but blocks outbound API side effects before auth validation.
|
||||
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The auth block keeps BOTH its own heading and its own enable/disable hint after the merge: the URL hint above describes what disabling does to imports/refresh, while this one describes the PRIVATE-TOKEN auth contract and the token's global fallback. Neither string is a paraphrase of the other, so collapsing them into one would delete operator-facing copy rather than deduplicate it.
|
||||
*/}
|
||||
<h5 className="settings-section-heading">{t("settings.merge.gitLabAuthentication", "GitLab Authentication")}</h5>
|
||||
<small className="settings-description">{form.gitlabEnabled === false ? t("settings.merge.gitLabDisabledHint", "GitLab comments, close/reopen, import fetches, and refresh operations are disabled. Saved tokens remain stored for re-enable.") : t("settings.merge.gitLabAuthDetails", "Fusion uses GitLab REST API token authentication with the PRIVATE-TOKEN header. Leave the token blank to clear the project override and fall back to a configured global GitLab token or GITLAB_TOKEN where available. No default — unset (unset behaves as enabled until explicitly disabled).")}</small>
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "gitlabAuthTokenType",
|
||||
label: t("settings.merge.gitLabTokenType", "GitLab token type"),
|
||||
disabled: form.gitlabEnabled === false,
|
||||
options: [
|
||||
{ value: "personal", label: t("settings.merge.gitLabPersonalAccessToken", "Personal access token (default)") },
|
||||
{ value: "project", label: t("settings.merge.gitLabProjectAccessToken", "Project access token") },
|
||||
{ value: "group", label: t("settings.merge.gitLabGroupAccessToken", "Group access token") },
|
||||
],
|
||||
}}
|
||||
value={form.gitlabAuthTokenType ?? "personal"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, gitlabAuthTokenType: v as "personal" | "project" | "group" }))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "gitlabAuthToken",
|
||||
label: t("settings.merge.gitLabAccessToken", "GitLab access token"),
|
||||
help: t("settings.merge.gitLabAuthTokenHint", "Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership. No default — unset."),
|
||||
type: "password",
|
||||
disabled: form.gitlabEnabled === false,
|
||||
}}
|
||||
value={form.gitlabAuthToken ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, gitlabAuthToken: v || undefined }))}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</>);
|
||||
}
|
||||
export default SourceControlSection;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Search entries for the Worktrees section.
|
||||
*
|
||||
* FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
* One entry per descriptor row the section renders, co-located so a setting and its index entry change in the same edit. `settings-search-index.test.ts` fails the build if a descriptor `key` here and in WorktreesSection.tsx ever diverge, which is what keeps the index honest without anyone maintaining a keyword list by hand.
|
||||
* Labels and help mirror the section's `t()` calls verbatim: the index matches on the copy operators actually read, so a paraphrase here would make search miss the words on screen.
|
||||
* The section's bespoke controls (worktree copy-file list, worktrees directory picker, sibling-branch-rename toggle, rebase remote select, and the worktrunk block) render no descriptor rows and so carry no entries; the nav entry's own `searchableText` still surfaces the section for those.
|
||||
*/
|
||||
import type { SettingsSearchEntry } from "../search/types";
|
||||
|
||||
export const worktreesSearchEntries: SettingsSearchEntry[] = [
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "maxWorktrees",
|
||||
labelKey: "settings.worktrees.maxWorktrees",
|
||||
labelFallback: "Max Worktrees",
|
||||
helpKey: "settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks",
|
||||
helpFallback: "Limits total git worktrees including in-review tasks. Default: 4.",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Concurrency vocabulary is indexed here because this key — not `maxConcurrent` — is the real task-parallelism cap (it gates in-progress worktree holders), and operators searching "concurrency" or "parallel" would otherwise land only on Scheduling.
|
||||
*/
|
||||
keywords: ["concurrency", "parallel tasks", "capacity", "limit"],
|
||||
},
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "worktreeInitCommand",
|
||||
labelKey: "settings.worktrees.worktreeInitCommand",
|
||||
labelFallback: "Worktree Init Command",
|
||||
helpKey: "settings.worktrees.shellCommandToRunInEachNewWorktree",
|
||||
helpFallback: "Shell command to run in each new worktree after creation. No default — unset.",
|
||||
keywords: ["setup script", "bootstrap", "install dependencies", "post-create"],
|
||||
},
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "recycleWorktrees",
|
||||
labelKey: "settings.worktrees.recycleWorktrees",
|
||||
labelFallback: " Recycle worktrees ",
|
||||
helpKey: "settings.worktrees.offByDefaultOptInWhenEnabledCompleted",
|
||||
helpFallback:
|
||||
"Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup",
|
||||
keywords: ["reuse", "warm pool"],
|
||||
},
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "showWorktreeGrouping",
|
||||
labelKey: "settings.worktrees.showWorktreeGrouping",
|
||||
labelFallback: " Show worktree grouping on the board ",
|
||||
helpKey: "settings.worktrees.showWorktreeGroupingHelp",
|
||||
helpFallback:
|
||||
"Off by default. When enabled, WIP and processing columns always group tasks by worktree and show worktree names, including workflow-mode processing columns.",
|
||||
keywords: ["group by", "swimlane"],
|
||||
},
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "worktreeNaming",
|
||||
labelKey: "settings.worktrees.worktreeNamingStyle",
|
||||
labelFallback: "Worktree Naming Style",
|
||||
/*
|
||||
FNXC:SettingsSearch 2026-07-15-17:35:
|
||||
Indexed against the enabled help string. The disabled variant ("not applicable when recycling") is a transient state of one checkbox, not a second setting, so indexing it would make search results read as though recycling were on.
|
||||
*/
|
||||
helpKey: "settings.worktrees.howToNameFreshWorktreeDirectories",
|
||||
helpFallback: "How to name fresh worktree directories. Only applies when recycling is off. Default: random.",
|
||||
keywords: ["folder name", "directory name", "branch naming"],
|
||||
},
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "worktreeRebaseBeforeMerge",
|
||||
labelKey: "settings.worktrees.rebaseFromRemoteBeforeMerge",
|
||||
labelFallback: " Rebase from remote before merge ",
|
||||
helpKey: "settings.worktrees.whenEnabledTheMergerFetchesFromTheConfigured",
|
||||
helpFallback:
|
||||
"When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging — catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline. Default: enabled.",
|
||||
keywords: ["pull", "up to date", "prerebase"],
|
||||
},
|
||||
{
|
||||
sectionId: "worktrees",
|
||||
key: "worktreeRebaseLocalBase",
|
||||
labelKey: "settings.worktrees.alsoRebaseOntoLocalDefaultBranchHEAD",
|
||||
labelFallback: " Also rebase onto local default-branch HEAD ",
|
||||
helpKey: "settings.worktrees.inAdditionToTheRemoteRebaseAboveAlso",
|
||||
helpFallback:
|
||||
" In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet — without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. ",
|
||||
keywords: ["main", "unpushed", "prerebase"],
|
||||
},
|
||||
];
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitRemoteDetailed } from "../../../api";
|
||||
import type { useWorktrunkInstallStatus } from "../../../hooks/useWorktrunkInstallStatus";
|
||||
import { SettingsToggleRow } from "../SettingsToggleRow";
|
||||
import { SettingsSelectRow } from "../SettingsSelectRow";
|
||||
import { SettingsNumberRow } from "../SettingsNumberRow";
|
||||
import { SettingsTextRow } from "../SettingsTextRow";
|
||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { SectionBaseProps, SettingsFormState } from "./context";
|
||||
export interface WorktreesSectionProps extends SectionBaseProps {
|
||||
scopeBanner: ReactNode;
|
||||
gitRemotes: GitRemoteDetailed[];
|
||||
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
|
||||
worktrunkInstallVerified: boolean;
|
||||
@@ -15,37 +18,79 @@ export interface WorktreesSectionProps extends SectionBaseProps {
|
||||
onOpenWorktreeCopyFilePicker: (index: number) => void;
|
||||
onOpenApprovals?: (approvalId?: string) => void;
|
||||
}
|
||||
export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onWorktreeCopyFileChange, onRemoveWorktreeCopyFile, onAddWorktreeCopyFile, onOpenWorktreeCopyFilePicker, onOpenApprovals, }: WorktreesSectionProps) {
|
||||
/*
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
The plain label+control+help rows here render through the shared settings primitives instead of hand-rolled `form-group` + `checkbox-label` markup, so their labels, help copy, and padding come from the one settings type scale. `.form-group` itself stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath the rest of the dashboard.
|
||||
|
||||
FNXC:SettingsScope 2026-07-15-17:35:
|
||||
Every migrated key in this section is project-scoped (`DEFAULT_PROJECT_SETTINGS`): worktree count, layout, naming, and rebase policy describe one repository's checkout strategy and must not follow the operator to another project. The badges restate that per row because settings search can land an operator on a single control with no section chrome in view.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-17:35:
|
||||
Four groups deliberately keep their bespoke markup because they are not plain label+control+help rows:
|
||||
- The `worktreeCopyFiles` allowlist is a repeating row editor with per-row Browse/Remove buttons.
|
||||
- `worktreesDir` pairs its input with a Browse button and swaps in rich `<code>`-bearing help.
|
||||
- `executorAllowSiblingBranchRename` and `worktreeRebaseRemote` compose their help from several `t()` fragments interleaved with `<code>` elements; a descriptor `help` is a single string, and flattening that copy would reword operator-facing text.
|
||||
- The whole worktrunk block edits one nested `worktrunk` object (not a top-level settings key), carries `<code>`-bearing help, cross-field disabled logic, and an install affordance.
|
||||
|
||||
FNXC:SettingsHelp 2026-07-15-21:40:
|
||||
Those bespoke rows still hang their help off the same "?" as the migrated ones: each one's `<small>` moved into a `SettingsHelpTip` beside its label (`.settings-field-label-row`), so the section reads as one idiom instead of "rows with a help icon" next to "rows with a paragraph". The tip takes `ReactNode`, so the `<code>`-bearing copy above moves verbatim.
|
||||
The worktrunk install affordance keeps its inline `<small>`s: install state, the installed path/version, and the "install the binary below to enable this" precondition are live status and operator next-steps, not a description of what a control does — deferring them behind a "?" would hide the reason a control is disabled.
|
||||
*/
|
||||
export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onWorktreeCopyFileChange, onRemoveWorktreeCopyFile, onAddWorktreeCopyFile, onOpenWorktreeCopyFilePicker, onOpenApprovals, }: WorktreesSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
|
||||
{/* FNXC:Worktrees 2026-07-15-17:35: An emptied Max Worktrees stores `undefined`, not 0 or "", so the key is absent from the settings blob and the scheduler falls back to the schema default of 4 rather than capping concurrency at nothing. */}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "maxWorktrees",
|
||||
label: t("settings.worktrees.maxWorktrees", "Max Worktrees"),
|
||||
help: t("settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", "Limits total git worktrees including in-review tasks. Default: 4."),
|
||||
scope: "project",
|
||||
min: 1,
|
||||
max: 20,
|
||||
}}
|
||||
value={form.maxWorktrees ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, maxWorktrees: v ?? undefined } as SettingsFormState))}
|
||||
/>
|
||||
<SettingsTextRow
|
||||
descriptor={{
|
||||
key: "worktreeInitCommand",
|
||||
label: t("settings.worktrees.worktreeInitCommand", "Worktree Init Command"),
|
||||
help: t("settings.worktrees.shellCommandToRunInEachNewWorktree", "Shell command to run in each new worktree after creation. No default \u2014 unset."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.worktrees.pnpmInstallFrozenLockfile", "pnpm install --frozen-lockfile"),
|
||||
}}
|
||||
value={form.worktreeInitCommand ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, worktreeInitCommand: v ?? "" }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "recycleWorktrees",
|
||||
label: t("settings.worktrees.recycleWorktrees", " Recycle worktrees "),
|
||||
help: t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup"),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.recycleWorktrees === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, recycleWorktrees: v === true }))}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "showWorktreeGrouping",
|
||||
label: t("settings.worktrees.showWorktreeGrouping", " Show worktree grouping on the board "),
|
||||
help: t("settings.worktrees.showWorktreeGroupingHelp", "Off by default. When enabled, WIP and processing columns always group tasks by worktree and show worktree names, including workflow-mode processing columns."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.showWorktreeGrouping === true}
|
||||
onChange={(v) => setForm((f) => ({ ...f, showWorktreeGrouping: v === true }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxWorktrees">{t("settings.worktrees.maxWorktrees", "Max Worktrees")}</label>
|
||||
<input id="maxWorktrees" type="number" min={1} max={20} value={form.maxWorktrees ?? ""} onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as SettingsFormState));
|
||||
}}/>
|
||||
<small>{t("settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", "Limits total git worktrees including in-review tasks. Default: 4.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreeInitCommand">{t("settings.worktrees.worktreeInitCommand", "Worktree Init Command")}</label>
|
||||
<input id="worktreeInitCommand" type="text" placeholder={t("settings.worktrees.pnpmInstallFrozenLockfile", "pnpm install --frozen-lockfile")} value={form.worktreeInitCommand || ""} onChange={(e) => setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))}/>
|
||||
<small>{t("settings.worktrees.shellCommandToRunInEachNewWorktree", "Shell command to run in each new worktree after creation. No default \u2014 unset.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="recycleWorktrees" className="checkbox-label">
|
||||
<input id="recycleWorktrees" type="checkbox" checked={form.recycleWorktrees} onChange={(e) => setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")}</label>
|
||||
<small>{t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="showWorktreeGrouping" className="checkbox-label">
|
||||
<input id="showWorktreeGrouping" type="checkbox" checked={form.showWorktreeGrouping === true} onChange={(e) => setForm((f) => ({ ...f, showWorktreeGrouping: e.target.checked }))}/>{t("settings.worktrees.showWorktreeGrouping", " Show worktree grouping on the board ")}</label>
|
||||
<small>{t("settings.worktrees.showWorktreeGroupingHelp", "Off by default. When enabled, WIP and processing columns always group tasks by worktree and show worktree names, including workflow-mode processing columns.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t("settings.worktrees.filesToCopyIntoNewWorktrees", "Files to copy into new worktrees")}</label>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-21:40: The allowlist is a repeating row editor, but it is still one settings key (`worktreeCopyFiles`), so its help hangs off the group's own label rather than any single path input. */}
|
||||
<div className="settings-field-label-row">
|
||||
<label>{t("settings.worktrees.filesToCopyIntoNewWorktrees", "Files to copy into new worktrees")}</label>
|
||||
<SettingsHelpTip settingKey="worktreeCopyFiles">{t("settings.worktrees.copyFilesHelp", "Optional. Repository-root-relative regular files are copied into fresh or pooled task worktrees before init commands run. Missing files or directories are skipped without exposing contents. Default: empty (no files copied).")}</SettingsHelpTip>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:WorktreeCopyFiles 2026-06-24-00:00:
|
||||
Users need a visible, editable allowlist for repository files such as `.env` that Fusion copies into freshly prepared task worktrees. The UI preserves blank rows while editing, but save normalization trims, removes blanks, and de-duplicates before persistence.
|
||||
@@ -86,72 +131,100 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
|
||||
<button type="button" className="btn btn-sm" onClick={onAddWorktreeCopyFile}>
|
||||
{t("settings.worktrees.addCopyFile", "Add file")}
|
||||
</button>
|
||||
<small>{t("settings.worktrees.copyFilesHelp", "Optional. Repository-root-relative regular files are copied into fresh or pooled task worktrees before init commands run. Missing files or directories are skipped without exposing contents. Default: empty (no files copied).")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
|
||||
<input id="executorAllowSiblingBranchRename" type="checkbox" checked={form.executorAllowSiblingBranchRename === true} onChange={(e) => setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))}/>{t("settings.worktrees.allowSilentSiblingBranchRenameDuringExecutorConflicts", " Allow silent sibling branch rename during executor conflicts ")}</label>
|
||||
<small>{t("settings.worktrees.discouragedThisRestoresTheLegacyBehaviorWhereA", " Discouraged. This restores the legacy behavior where a live ")}<code>fusion/<task-id></code>{t("settings.worktrees.branchCollisionSilentlyForksWorkOntoSiblingBranches", " branch collision silently forks work onto sibling branches like ")}<code>-2</code>{t("settings.worktrees.andCanHidePriorCommitsFromTheDefault", " and can hide prior commits from the default recovery flow. Default: disabled. ")}</small>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
|
||||
<input id="executorAllowSiblingBranchRename" type="checkbox" checked={form.executorAllowSiblingBranchRename === true} onChange={(e) => setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))}/>{t("settings.worktrees.allowSilentSiblingBranchRenameDuringExecutorConflicts", " Allow silent sibling branch rename during executor conflicts ")}</label>
|
||||
<SettingsHelpTip settingKey="executorAllowSiblingBranchRename">{t("settings.worktrees.discouragedThisRestoresTheLegacyBehaviorWhereA", " Discouraged. This restores the legacy behavior where a live ")}<code>fusion/<task-id></code>{t("settings.worktrees.branchCollisionSilentlyForksWorkOntoSiblingBranches", " branch collision silently forks work onto sibling branches like ")}<code>-2</code>{t("settings.worktrees.andCanHidePriorCommitsFromTheDefault", " and can hide prior commits from the default recovery flow. Default: disabled. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreeNaming">{t("settings.worktrees.worktreeNamingStyle", "Worktree Naming Style")}</label>
|
||||
<select id="worktreeNaming" value={form.worktreeNaming || "random"} onChange={(e) => setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))} disabled={form.recycleWorktrees}>
|
||||
<option value="random">{t("settings.worktrees.randomNamesEGSwiftFalcon", "Random names (e.g., swift-falcon)")}</option>
|
||||
<option value="task-id">{t("settings.worktrees.taskIDEGFN042", "Task ID (e.g., FN-042)")}</option>
|
||||
<option value="task-title">{t("settings.worktrees.taskTitleEGFixLoginBug", "Task title (e.g., fix-login-bug)")}</option>
|
||||
</select>
|
||||
<small>
|
||||
{form.recycleWorktrees
|
||||
{/*
|
||||
FNXC:Worktrees 2026-07-15-17:35:
|
||||
Recycling and naming are coupled: pooled worktrees keep the names they were created with, so the naming select is disabled while `recycleWorktrees` is on and its help swaps to explain why rather than letting the operator pick a style that would be silently ignored.
|
||||
*/}
|
||||
<SettingsSelectRow
|
||||
descriptor={{
|
||||
key: "worktreeNaming",
|
||||
label: t("settings.worktrees.worktreeNamingStyle", "Worktree Naming Style"),
|
||||
help: form.recycleWorktrees
|
||||
? t("settings.worktrees.namingStyleNotApplicableWhenRecycling", "Naming style is not applicable when recycling worktrees \u2014 pooled worktrees retain their existing names")
|
||||
: t("settings.worktrees.howToNameFreshWorktreeDirectories", "How to name fresh worktree directories. Only applies when recycling is off. Default: random.")}
|
||||
</small>
|
||||
</div>
|
||||
: t("settings.worktrees.howToNameFreshWorktreeDirectories", "How to name fresh worktree directories. Only applies when recycling is off. Default: random."),
|
||||
scope: "project",
|
||||
disabled: form.recycleWorktrees,
|
||||
options: [
|
||||
{ value: "random", label: t("settings.worktrees.randomNamesEGSwiftFalcon", "Random names (e.g., swift-falcon)") },
|
||||
{ value: "task-id", label: t("settings.worktrees.taskIDEGFN042", "Task ID (e.g., FN-042)") },
|
||||
{ value: "task-title", label: t("settings.worktrees.taskTitleEGFixLoginBug", "Task title (e.g., fix-login-bug)") },
|
||||
],
|
||||
}}
|
||||
value={form.worktreeNaming || "random"}
|
||||
onChange={(v) => setForm((f) => ({ ...f, worktreeNaming: v as "random" | "task-id" | "task-title" }))}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreesDir">{t("settings.worktrees.worktreesDirectory", "Worktrees Directory")}</label>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-21:40: The help swaps to the worktrunk-disabled explanation, so the tip is what tells an operator why the input is greyed out; it stays on the same "?" as every other row rather than becoming a second inline idiom. */}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="worktreesDir">{t("settings.worktrees.worktreesDirectory", "Worktrees Directory")}</label>
|
||||
<SettingsHelpTip settingKey="worktreesDir">
|
||||
{form.worktrunk?.enabled === true
|
||||
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
|
||||
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Defaults to ")}<code><projectRoot>/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
|
||||
</SettingsHelpTip>
|
||||
</div>
|
||||
<div className="settings-overlap-ignore-path-controls">
|
||||
<input id="worktreesDir" type="text" placeholder={t("settings.worktrees.defaultsToWorktreesLeaveEmptyUnlessOverriding", "Defaults to .worktrees \u2014 leave empty unless overriding")} value={form.worktreesDir || ""} disabled={form.worktrunk?.enabled === true} onChange={(e) => setForm((f) => ({ ...f, worktreesDir: e.target.value }))}/>
|
||||
<button type="button" className="btn btn-sm" onClick={onOpenWorktreesDirPicker} aria-label={t("settings.worktrees.browseWorktreesDirectory", "Browse worktrees directory")} disabled={form.worktrunk?.enabled === true}>{t("settings.worktrees.browse", " Browse ")}</button>
|
||||
</div>
|
||||
<small>
|
||||
{form.worktrunk?.enabled === true
|
||||
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
|
||||
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Defaults to ")}<code><projectRoot>/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreeRebaseBeforeMerge" className="checkbox-label">
|
||||
<input id="worktreeRebaseBeforeMerge" type="checkbox" checked={form.worktreeRebaseBeforeMerge !== false} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: e.target.checked }))}/>{t("settings.worktrees.rebaseFromRemoteBeforeMerge", " Rebase from remote before merge ")}</label>
|
||||
<small>{t("settings.worktrees.whenEnabledTheMergerFetchesFromTheConfigured", "When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging \u2014 catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline. Default: enabled.")}</small>
|
||||
</div>
|
||||
{/* FNXC:Worktrees 2026-07-15-17:35: Defaults to on, so an absent key reads as enabled (`!== false`) rather than off \u2014 an unset settings blob must not silently skip the pre-merge rebase. */}
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "worktreeRebaseBeforeMerge",
|
||||
label: t("settings.worktrees.rebaseFromRemoteBeforeMerge", " Rebase from remote before merge "),
|
||||
help: t("settings.worktrees.whenEnabledTheMergerFetchesFromTheConfigured", "When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging \u2014 catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline. Default: enabled."),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.worktreeRebaseBeforeMerge !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: v === true }))}
|
||||
/>
|
||||
{form.worktreeRebaseBeforeMerge !== false && (<div className="form-group">
|
||||
<label htmlFor="worktreeRebaseRemote">{t("settings.worktrees.rebaseRemote", "Rebase Remote")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="worktreeRebaseRemote">{t("settings.worktrees.rebaseRemote", "Rebase Remote")}</label>
|
||||
<SettingsHelpTip settingKey="worktreeRebaseRemote">{t("settings.worktrees.whichRemoteToFetchForThePreMerge", " Which remote to fetch for the pre-merge rebase. \"Use git default\" falls back to the remote configured for the default branch (typically ")}<code>origin</code>{t("settings.worktrees.closeParenPeriod", ").")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="worktreeRebaseRemote" value={form.worktreeRebaseRemote ?? ""} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))}>
|
||||
<option value="">{t("settings.worktrees.useGitDefault", "Use git default")}</option>
|
||||
{gitRemotes.map((remote) => (<option key={remote.name} value={remote.name}>
|
||||
{remote.name} ({remote.fetchUrl})
|
||||
</option>))}
|
||||
</select>
|
||||
<small>{t("settings.worktrees.whichRemoteToFetchForThePreMerge", " Which remote to fetch for the pre-merge rebase. \"Use git default\" falls back to the remote configured for the default branch (typically ")}<code>origin</code>{t("settings.worktrees.closeParenPeriod", ").")}
|
||||
</small>
|
||||
</div>)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreeRebaseLocalBase" className="checkbox-label">
|
||||
<input id="worktreeRebaseLocalBase" type="checkbox" checked={form.worktreeRebaseLocalBase !== false} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseLocalBase: e.target.checked }))}/>{t("settings.worktrees.alsoRebaseOntoLocalDefaultBranchHEAD", " Also rebase onto local default-branch HEAD ")}</label>
|
||||
<small>{t("settings.worktrees.inAdditionToTheRemoteRebaseAboveAlso", " In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet \u2014 without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. ")}</small>
|
||||
</div>
|
||||
<SettingsToggleRow
|
||||
descriptor={{
|
||||
key: "worktreeRebaseLocalBase",
|
||||
label: t("settings.worktrees.alsoRebaseOntoLocalDefaultBranchHEAD", " Also rebase onto local default-branch HEAD "),
|
||||
help: t("settings.worktrees.inAdditionToTheRemoteRebaseAboveAlso", " In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet \u2014 without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. "),
|
||||
scope: "project",
|
||||
}}
|
||||
value={form.worktreeRebaseLocalBase !== false}
|
||||
onChange={(v) => setForm((f) => ({ ...f, worktreeRebaseLocalBase: v === true }))}
|
||||
/>
|
||||
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.worktrees.worktrunkIntegration", "Worktrunk integration")}</h4>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktrunkEnabled" className="checkbox-label">
|
||||
<input id="worktrunkEnabled" type="checkbox" checked={form.worktrunk?.enabled === true} disabled={!worktrunkInstallVerified && form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
worktrunk: {
|
||||
enabled: e.target.checked,
|
||||
binaryPath: f.worktrunk?.binaryPath ?? "",
|
||||
onFailure: f.worktrunk?.onFailure ?? "fail",
|
||||
},
|
||||
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}</label>
|
||||
<small>{t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}<code>worktrunk</code>{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}</small>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="worktrunkEnabled" className="checkbox-label">
|
||||
<input id="worktrunkEnabled" type="checkbox" checked={form.worktrunk?.enabled === true} disabled={!worktrunkInstallVerified && form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
worktrunk: {
|
||||
enabled: e.target.checked,
|
||||
binaryPath: f.worktrunk?.binaryPath ?? "",
|
||||
onFailure: f.worktrunk?.onFailure ?? "fail",
|
||||
},
|
||||
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}</label>
|
||||
<SettingsHelpTip settingKey="worktrunkEnabled">{t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}<code>worktrunk</code>{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-21:40: Stays inline: this is the reason the checkbox above is disabled and the action that clears it, not a description of the setting. Behind a "?" the operator would see a dead toggle with no explanation in view. */}
|
||||
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (<small className="settings-muted">{t("settings.worktrees.installTheWorktrunkBinaryBelowToEnableThis", "Install the worktrunk binary below to enable this integration.")}</small>)}
|
||||
</div>
|
||||
<div className="form-group" data-testid="worktrunk-install-affordance">
|
||||
@@ -177,7 +250,10 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
|
||||
</>)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktrunkBinaryPath">{t("settings.worktrees.worktrunkBinaryPath", "Worktrunk binary path")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="worktrunkBinaryPath">{t("settings.worktrees.worktrunkBinaryPath", "Worktrunk binary path")}</label>
|
||||
<SettingsHelpTip settingKey="worktrunkBinaryPath">{t("settings.worktrees.optionalLeaveBlankToAutoResolveFusionWill", "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<input id="worktrunkBinaryPath" type="text" className="input" placeholder={t("settings.worktrees.autoDetectFusionBinWorktrunkOrPATH", "auto-detect (~/.fusion/bin/worktrunk or $PATH)")} value={form.worktrunk?.binaryPath ?? ""} disabled={form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
worktrunk: {
|
||||
@@ -186,10 +262,12 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
|
||||
onFailure: f.worktrunk?.onFailure ?? "fail",
|
||||
},
|
||||
}))}/>
|
||||
<small>{t("settings.worktrees.optionalLeaveBlankToAutoResolveFusionWill", "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktrunkOnFailure">{t("settings.worktrees.worktrunkFailureBehavior", "Worktrunk failure behavior")}</label>
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="worktrunkOnFailure">{t("settings.worktrees.worktrunkFailureBehavior", "Worktrunk failure behavior")}</label>
|
||||
<SettingsHelpTip settingKey="worktrunkOnFailure"><code>fail</code>{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}<code>fallback-native</code>{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="worktrunkOnFailure" className="select" value={form.worktrunk?.onFailure ?? "fail"} disabled={form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
worktrunk: {
|
||||
@@ -201,8 +279,6 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
|
||||
<option value="fail">{t("settings.worktrees.failAndPauseTheTaskDefault", "Fail and pause the task (default)")}</option>
|
||||
<option value="fallback-native">{t("settings.worktrees.fallBackToFusionsNativeWorktreeBackend", "Fall back to Fusion's native worktree backend")}</option>
|
||||
</select>
|
||||
<small>
|
||||
<code>fail</code>{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}<code>fallback-native</code>{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}</small>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
|
||||
|
||||
render(
|
||||
<AppearanceSection
|
||||
scopeBanner={<div data-testid="scope-banner" />}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
themeMode="dark"
|
||||
@@ -70,6 +69,13 @@ describe("AppearanceSection", () => {
|
||||
|
||||
const checkbox = screen.getByLabelText("Open tasks as popups");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
/*
|
||||
FNXC:MobileTaskPopups 2026-07-15-17:35:
|
||||
This assertion tracked copy that FN-7945 deliberately rewrote — the setting became all-viewport, so the help text gained "List row/card" and the popup became "movable" — and it had been failing against the shipped string ever since.
|
||||
Realigned to the copy the section actually renders rather than deleted: the requirement (the help text must state which click targets route to the popup) is still worth asserting, and dropping it would leave the copy uncovered.
|
||||
*/
|
||||
expect(screen.getByText(/ordinary board task-card, List row\/card, and right-dock Tasks-list clicks open the existing movable task popup/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Deep-tab and other task opens keep their current behavior/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ function GeneralHost({ initialForm, onSetForm }: {
|
||||
const [form, setForm] = useState(initialForm as SettingsFormState);
|
||||
return (
|
||||
<GeneralSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={(updater) => {
|
||||
setForm((prev) => {
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
FNXC:GitHubImportTranslate 2026-07-15-16:35:
|
||||
The import auto-translate controls must render with the SECTION'S native checkbox idiom — a plain
|
||||
`checkbox-label` with the input BEFORE the text — not the right-aligned toggle-switch primitive that
|
||||
SettingsToggleRow renders. Two different checkbox idioms in one settings section read as a bug, so
|
||||
this pins the markup (and its parity with the neighbouring GitHub/import checkbox) rather than
|
||||
trusting it to survive a refactor back onto the primitive.
|
||||
The import auto-translate controls must render with the SECTION'S native checkbox idiom — a native
|
||||
checkbox with the input BEFORE its text, matching the neighbouring GitHub/import checkbox. Two
|
||||
different checkbox idioms in one settings section read as a bug.
|
||||
|
||||
FNXC:SettingsStyling 2026-07-15-19:10:
|
||||
The requirement above is unchanged; what satisfies it moved. This originally pinned the literal
|
||||
`checkbox-label` class and was written to survive "a refactor back onto the primitive", because
|
||||
SettingsToggleRow then rendered a right-aligned toggle switch that clashed with the section's
|
||||
checkboxes.
|
||||
|
||||
Both halves of that objection are now gone: SettingsToggleRow renders a native checkbox BEFORE its
|
||||
label (SettingsFieldRow `inlineControl`), and every checkbox in this section — including the
|
||||
neighbour this asserts parity against — renders through the same primitive. The idiom split that
|
||||
motivated the pin is resolved by migrating all of them rather than by de-migrating these two, so the
|
||||
assertions below track the primitive's markup instead of `checkbox-label`.
|
||||
|
||||
The behavioural contracts are untouched and still pinned: input-before-text, exact parity with the
|
||||
neighbouring checkbox, `undefined`-not-`false` on switch-off, and inherit-on-blank.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
@@ -64,36 +77,46 @@ function GeneralHost({ initialForm, onSetForm }: {
|
||||
addToast={vi.fn()}
|
||||
prefixError={null}
|
||||
setPrefixError={vi.fn()}
|
||||
projectTrackingRepoOptions={[]}
|
||||
projectTrackingRepoLoading={false}
|
||||
projectTrackingRepoError={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("GeneralSection - import auto-translate controls", () => {
|
||||
it("renders the auto-translate control as a checkbox using the section's checkbox-label idiom", () => {
|
||||
it("renders the auto-translate control as a native checkbox using the section's row idiom", () => {
|
||||
render(<GeneralHost initialForm={{}} />);
|
||||
const input = document.getElementById("githubImportAutoTranslate") as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
// Still a native checkbox — not a bespoke switch widget.
|
||||
expect(input.type).toBe("checkbox");
|
||||
expect(input.closest("label")?.className).toContain("checkbox-label");
|
||||
expect(input.closest(".settings-field-row")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("puts the checkbox BEFORE its text, like every other checkbox in the section", () => {
|
||||
render(<GeneralHost initialForm={{}} />);
|
||||
const input = document.getElementById("githubImportAutoTranslate")!;
|
||||
const label = input.closest("label")!;
|
||||
expect(label.firstElementChild).toBe(input);
|
||||
expect(label.textContent).toContain("Auto-translate imported issues");
|
||||
const row = input.closest(".settings-field-row")!;
|
||||
// Reading order is the requirement: "[x] Auto-translate imported issues".
|
||||
// The primitive binds label->control via htmlFor/id, so the label is a
|
||||
// sibling of the control rather than its wrapper; assert DOM order directly.
|
||||
const head = row.querySelector(".settings-field-row-head")!;
|
||||
const controlSlot = head.querySelector(".settings-field-row-control")!;
|
||||
expect(controlSlot.contains(input)).toBe(true);
|
||||
expect(head.firstElementChild).toBe(controlSlot);
|
||||
expect(row.textContent).toContain("Auto-translate imported issues");
|
||||
});
|
||||
|
||||
it("matches the neighbouring imported-issue checkbox's structure exactly", () => {
|
||||
/*
|
||||
FNXC:SourceControl 2026-07-15-20:30:
|
||||
The parity neighbour used to be `githubLinkImportedIssuesToTracking`, which moved to "Source Control · Project" with the rest of the GitHub tracking block. The REQUIREMENT is unchanged — auto-translate must not become a second checkbox idiom inside General — so this now compares against a checkbox General still renders. Any of the section's toggles serves: they all render through SettingsToggleRow, which is the invariant being pinned.
|
||||
*/
|
||||
it("matches a neighbouring section checkbox's structure exactly", () => {
|
||||
render(<GeneralHost initialForm={{}} />);
|
||||
const mine = document.getElementById("githubImportAutoTranslate")!.closest("label")!;
|
||||
const neighbour = document.getElementById("githubLinkImportedIssuesToTracking")!.closest("label")!;
|
||||
const mine = document.getElementById("githubImportAutoTranslate")!.closest(".settings-field-row")!;
|
||||
const neighbour = document.getElementById("showTaskChatsInCommonFeed")!.closest(".settings-field-row")!;
|
||||
// Parity is the point: both render through the same row primitive, so an
|
||||
// idiom split cannot reappear in this section.
|
||||
expect(mine.className).toBe(neighbour.className);
|
||||
expect(mine.firstElementChild?.tagName).toBe(neighbour.firstElementChild?.tagName);
|
||||
expect(mine.firstElementChild?.className).toBe(neighbour.firstElementChild?.className);
|
||||
});
|
||||
|
||||
it("is unchecked by default and stores the opt-in when toggled", () => {
|
||||
@@ -118,7 +141,7 @@ describe("GeneralSection - import auto-translate controls", () => {
|
||||
|
||||
it("renders the target-language select with the section's select idiom and the inherit option", () => {
|
||||
render(<GeneralHost initialForm={{}} />);
|
||||
const select = screen.getByTestId("import-translate-target-locale-select") as HTMLSelectElement;
|
||||
const select = document.getElementById("importTranslateTargetLocale") as HTMLSelectElement;
|
||||
expect(select.className).toContain("select");
|
||||
expect(select.value).toBe("");
|
||||
expect([...select.options].map((o) => o.textContent)).toContain("Follow dashboard language");
|
||||
@@ -127,7 +150,7 @@ describe("GeneralSection - import auto-translate controls", () => {
|
||||
it("reflects and stores an explicit target locale", () => {
|
||||
let latest: SettingsFormState | undefined;
|
||||
render(<GeneralHost initialForm={{}} onSetForm={(f) => { latest = f; }} />);
|
||||
const select = screen.getByTestId("import-translate-target-locale-select") as HTMLSelectElement;
|
||||
const select = document.getElementById("importTranslateTargetLocale") as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "ko" } });
|
||||
expect(latest?.importTranslateTargetLocale).toBe("ko");
|
||||
@@ -136,7 +159,7 @@ describe("GeneralSection - import auto-translate controls", () => {
|
||||
it("stores undefined (inherit dashboard language) when the blank option is chosen", () => {
|
||||
let latest: SettingsFormState | undefined;
|
||||
render(<GeneralHost initialForm={{ importTranslateTargetLocale: "ko" } as Partial<SettingsFormState>} onSetForm={(f) => { latest = f; }} />);
|
||||
const select = screen.getByTestId("import-translate-target-locale-select") as HTMLSelectElement;
|
||||
const select = document.getElementById("importTranslateTargetLocale") as HTMLSelectElement;
|
||||
expect(select.value).toBe("ko");
|
||||
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
|
||||
@@ -93,7 +93,6 @@ function renderSection(initialForm: SettingsFormState = { defaultThinkingLevel:
|
||||
latestForm = form;
|
||||
return (
|
||||
<ProjectModelsSection
|
||||
scopeBanner={null}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
models={models}
|
||||
|
||||
@@ -40,6 +40,25 @@ export interface SettingsNumberDescriptor extends SettingsDescriptorBase {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input types a settings text row may render.
|
||||
*
|
||||
* FNXC:SettingsSecurity 2026-07-15-18:52:
|
||||
* `password` exists because the primitive previously hardcoded `type="text"`, which made every secret-bearing row unmigratable: ntfy access tokens and GitHub/GitLab/Cloudflare tunnel tokens would have rendered on screen in plain text. Rows stayed hand-rolled to stay masked, which is why they were also absent from the settings search index.
|
||||
* `url` carries the same intent for the URL fields those blocks sit beside — keeping a block's rows on one idiom rather than splitting it across primitive and bespoke markup.
|
||||
* Deliberately NOT the full HTML input-type surface: this is the set settings actually store. `number` has its own row primitive, and `email`/`tel`/`search` have no settings that use them — adding them speculatively would invite a caller to pick a type the row's string plumbing does not model.
|
||||
*/
|
||||
export type SettingsTextInputType = "text" | "password" | "url";
|
||||
|
||||
export interface SettingsTextDescriptor extends SettingsDescriptorBase {
|
||||
placeholder?: string;
|
||||
/** Input type. Defaults to `text`. */
|
||||
type?: SettingsTextInputType;
|
||||
/**
|
||||
* `autocomplete` attribute for the control.
|
||||
*
|
||||
* FNXC:SettingsSecurity 2026-07-15-18:52:
|
||||
* Defaults to `off` for `password` rows so a browser never offers to save or autofill a stored API token, matching the `autoComplete="off"` the hand-rolled token inputs carried. Secure-by-default: a caller who forgets it still gets the safe behavior, and opting back in has to be deliberate.
|
||||
*/
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
@@ -120,10 +120,22 @@ html {
|
||||
/* Typography */
|
||||
--font-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--font-mono: "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
/*
|
||||
FNXC:DashboardStyling 2026-07-15-17:35:
|
||||
The type scale must be complete and gap-free: every rung a component can name has to resolve to a real value.
|
||||
`--font-size-sm` and `--font-size-md` were named by 12 declarations across AgentDetailView, DockTaskList, SetupWizardModal, and ModelOnboardingModal while never being defined anywhere, so those rules silently no-op and the elements inherit instead.
|
||||
The command-center token-validity guard is the only gate that catches this class of omission, and it does not cover these files.
|
||||
Rung semantics, derived from existing call sites: 2xs = badges/scope pills, xs = help and caption copy, sm = control labels and body, base = default body, md = card and section headings (h3/h4), lg = modal titles.
|
||||
md is 1.125rem because unstyled h3 already renders at the 1.17em UA default here (no heading reset exists), so defining it holds current rendering rather than reflowing those views.
|
||||
*/
|
||||
--font-size-2xs: 0.6875rem;
|
||||
/* FNXC:DashboardStyling 2026-06-19-05:50: FN-6703 defines the xs font-size token so tokenized mobile mailbox tabs satisfy the dashboard CSS token-validity guard without relying on an undefined fallback. */
|
||||
--font-size-xs: 0.8rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
/* FNXC:DashboardStyling 2026-06-21-11:24: Dashboard components must use defined typography tokens so the raw CSS token-validity gate catches real missing custom properties instead of shared type-scale omissions. */
|
||||
--font-size-base: 1rem;
|
||||
--font-size-md: 1.125rem;
|
||||
--font-size-lg: 1.25rem;
|
||||
--line-height-tight: 1.25;
|
||||
|
||||
/* Spacing Scale */
|
||||
@@ -1768,28 +1780,6 @@ FN-7825 makes .settings-navigation the sole owner of Settings rail width and rem
|
||||
}
|
||||
|
||||
/* Scope banner above section content */
|
||||
.settings-scope-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
margin: 0 var(--space-xl) var(--space-xs);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.settings-scope-global {
|
||||
background: color-mix(in srgb, var(--color-info) 8%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-info) 40%, transparent);
|
||||
}
|
||||
.settings-scope-project {
|
||||
background: color-mix(in srgb, var(--color-success) 8%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-success) 40%, transparent);
|
||||
}
|
||||
.settings-scope-mixed {
|
||||
background: color-mix(in srgb, var(--triage) 8%, transparent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--triage) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Helper note styling for Settings sections — aligns with form-group horizontal gutters */
|
||||
.settings-note {
|
||||
@@ -3802,10 +3792,6 @@ Toast text must contrast its status background across every dashboard theme and
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.settings-scope-banner {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
}
|
||||
|
||||
.settings-empty-state {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,19 @@ await i18next.use(initReactI18next).init({
|
||||
toolCallCount_one: "{{count}} tool call",
|
||||
toolCallCount_other: "{{count}} tool calls",
|
||||
},
|
||||
/*
|
||||
FNXC:TestI18n 2026-07-15-17:35:
|
||||
Settings search reports its counts through i18next plural resolution, and its call sites intentionally pass NO inline default — a literal default would out-rank the catalog's singular form and reinstate "1 matching settings".
|
||||
That means these keys resolve from resources or not at all, so they are mirrored here (same `_one`/`_other` shape as the real en catalog) exactly as the taskChat counters above.
|
||||
*/
|
||||
settings: {
|
||||
search: {
|
||||
resultCount_one: "{{count}} matching section",
|
||||
resultCount_other: "{{count}} matching sections",
|
||||
settingResultCount_one: "{{count}} matching setting",
|
||||
settingResultCount_other: "{{count}} matching settings",
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {},
|
||||
},
|
||||
|
||||
@@ -5691,7 +5691,7 @@
|
||||
"language": "Language",
|
||||
"languageAuto": "Auto",
|
||||
"languageAutoHint": "Follow the browser language",
|
||||
"suppressTheLdquoNeedsYourInputRdquoBanner": " Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. ",
|
||||
"suppressTheLdquoNeedsYourInputRdquoBanner": "Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed.",
|
||||
"title": "Appearance",
|
||||
"openTasksInRightSidebarHelp": "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled.",
|
||||
"openMobileTasksInPopupHelp": "When enabled, ordinary board task-card and right-dock Tasks-list clicks open the existing task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.",
|
||||
@@ -5837,11 +5837,15 @@
|
||||
"allSections": "Showing all settings sections",
|
||||
"clear": "Clear settings search",
|
||||
"label": "Search settings",
|
||||
"moreResults": "{{count}} more — keep typing to narrow",
|
||||
"navigationLabel": "Settings navigation",
|
||||
"noMobileOptions": "No sections match this search.",
|
||||
"noResults": "No settings sections match \"{{query}}\".",
|
||||
"placeholder": "Search by setting or section",
|
||||
"resultCount": "{{count}} matching sections"
|
||||
"resultCount_one": "{{count}} matching section",
|
||||
"resultCount_other": "{{count}} matching sections",
|
||||
"settingResultCount_one": "{{count}} matching setting",
|
||||
"settingResultCount_other": "{{count}} matching settings"
|
||||
},
|
||||
"general": {
|
||||
"25": "25",
|
||||
@@ -6290,8 +6294,8 @@
|
||||
"global": "Shared across all projects",
|
||||
"project": "Specific to this project"
|
||||
},
|
||||
"globalMcp": "MCP Servers",
|
||||
"mcp": "MCP Servers"
|
||||
"globalMcp": "MCP Servers · Global",
|
||||
"mcp": "MCP Servers · Project"
|
||||
},
|
||||
"nodeRouting": {
|
||||
"blockExecution": "Block execution",
|
||||
|
||||
@@ -6265,8 +6265,8 @@
|
||||
"global": "Compartido en todos los proyectos",
|
||||
"project": "Específico de este proyecto"
|
||||
},
|
||||
"globalMcp": "MCP Servers",
|
||||
"mcp": "MCP Servers"
|
||||
"globalMcp": "MCP Servers · Global",
|
||||
"mcp": "MCP Servers · Project"
|
||||
},
|
||||
"nodeRouting": {
|
||||
"blockExecution": "",
|
||||
@@ -6718,11 +6718,13 @@
|
||||
"allSections": "",
|
||||
"clear": "",
|
||||
"label": "",
|
||||
"moreResults": "",
|
||||
"navigationLabel": "",
|
||||
"noMobileOptions": "",
|
||||
"noResults": "",
|
||||
"placeholder": "",
|
||||
"resultCount": ""
|
||||
"resultCount": "",
|
||||
"settingResultCount": ""
|
||||
},
|
||||
"prompts": {
|
||||
"surfaceExplanation": ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user