Part 3 of the capacity simplification, and the half that removes the **knob**. Enforcement (shared semaphore, runtime wiring) went in #2509; this removes everything an operator or API client can still see, so nothing is left readable-but-ignored. ## Deleted Settings key + schema default · CentralCore’s `getGlobalConcurrencyState` / `updateGlobalConcurrency` / `acquireGlobalSlot` / `releaseGlobalSlot` and the `concurrency:changed` event · the whole Global Concurrency block in `async-central-core` · `PUT /api/global-concurrency` · the Scheduling · Global settings section · the footer and Command Center global sliders · the dead `getGlobalConcurrencyLimit` reader whose only caller went in #2509. ## Kept, deliberately **`GET /api/global-concurrency` survives as telemetry only** — live `currentlyActive` / `projectsActive` from CentralCore’s side-effect-safe source. “How busy is this machine?” is still a real question once the cap that used to answer it is gone. It no longer reports `globalMaxConcurrent`/`queuedCount`: those came from the deleted cap and from slot bookkeeping production code never incremented, so publishing them was publishing zeros dressed as state. **`useGlobalConcurrency` becomes read-only.** Everything that existed to *persist* went with the cap — the 500 ms debounce, the save-state machine, the commit-on-close/unmount flush, the slider clamp, the `interactive` gate. The module-level shared store is **kept**: its original justification (two mounted consumers drift apart with private copies) holds for a polled read exactly as it did for a cap, and one fetch now serves both. The live “N running (all projects)” readout survives in both surfaces, moved onto the per-project row. ## Two sections become one Scheduling · Global existed to host exactly one control. With it deleted the section renders an empty pane, so the Global/Project pair merges back into **“Scheduling”**. An empty nav entry is a promise of settings that are not there. ## One real fix found on the way `SchedulingSection`’s `concurrencyLoading` gated the **project** concurrency inputs on the **global**-concurrency fetch — never the right source, since `maxConcurrent` and `maxWorktrees` come from the settings form. It is repointed at the form’s own load, preserving the invariant it existed for: a concurrency input stays disabled until its live value arrives, so an operator cannot overwrite a resolved limit with a blank fallback. ## Migration A stored `globalMaxConcurrent` is **ignored** — it is a project-blob key nothing reads, so dropping it needs no schema change. The `central.global_concurrency` **table** is dropped in a follow-up; this slice stops seeding and reading it first, so that drop has no live writer to race. ## Verification, and how the wider suite was controlled `pnpm lint` clean · core/engine/dashboard `tsc` clean · `pnpm test:gate` green (309 + 10 + 71) · dashboard settings/footer/command-center/hooks **2237/2237** · core `central-core-backend` 9/9. The broader dashboard suite shows failures, and I checked rather than assumed: running the suspect files on **clean main** reproduces `api-git` (49), `TaskDetailModal.rendering` (28) and `settings-mobile` (17) identically. Two were genuinely mine — `SettingsModal.scheduling-merge` (0 on main, 17 on this branch: my nav rename) and one `settings-mobile` picker case asserting `scheduling` is a scoped pair — and both are fixed. Tests for deleted behaviour are removed with it (footer confirm/cancel/flush/dedupe, global marker geometry, the hook’s PUT case, the CentralCore slot cases), each carrying a note on what it guarded and where the surviving **project-side** equivalent lives. Fixture-only references were updated, not deleted. Nothing booted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
5178 lines
228 KiB
TypeScript
5178 lines
228 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction } from "react";
|
|
import { Globe, Folder, GitBranch, Power, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
|
|
import {
|
|
getErrorMessage,
|
|
resolveGitlabConfig,
|
|
normalizeMergeIntegrationWorktreeMode,
|
|
normalizeMergeAdvanceAutoSyncMode,
|
|
} from "@fusion/core";
|
|
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
|
|
import { DEFAULT_GLOBAL_SETTINGS } from "@fusion/core";
|
|
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchSystemInfo, requestSystemRestart, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode, fetchPlugins } from "../api";
|
|
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
|
|
import { resolveScopedMcpSettings, splitSettingsSave, type McpSettingsScope } from "./settings/save-split";
|
|
import {
|
|
ALL_PROJECT_RESET_KEYS,
|
|
getResetIneligibleReason,
|
|
getSectionKeyEntry,
|
|
} from "./settings/section-keys";
|
|
import {
|
|
describeShortcutValidation,
|
|
normalizeKeyboardShortcut,
|
|
resolveDashboardKeyboardShortcuts,
|
|
type DashboardShortcutAction,
|
|
} from "../utils/keyboardShortcuts";
|
|
import type { DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts";
|
|
import { SettingsHelpTip } from "./settings/SettingsHelpTip";
|
|
import type { SectionSaveHandler } from "./settings/sections/context";
|
|
import { AppearanceSection } from "./settings/sections/AppearanceSection";
|
|
import { ExperimentalSection } from "./settings/sections/ExperimentalSection";
|
|
import { NodeSyncSection } from "./settings/sections/NodeSyncSection";
|
|
import { NotificationsSection } from "./settings/sections/NotificationsSection";
|
|
import { GlobalGeneralSection } from "./settings/sections/GlobalGeneralSection";
|
|
import { KeyboardShortcutsSection } from "./settings/sections/KeyboardShortcutsSection";
|
|
import { ResearchGlobalSection } from "./settings/sections/ResearchGlobalSection";
|
|
import { RemoteSection } from "./settings/sections/RemoteSection";
|
|
import { GlobalMcpSection } from "./settings/sections/GlobalMcpSection";
|
|
import { GlobalModelsSection } from "./settings/sections/GlobalModelsSection";
|
|
import { AuthenticationSection } from "./settings/sections/AuthenticationSection";
|
|
import {
|
|
HermesRuntimeSection,
|
|
OpenClawRuntimeSection,
|
|
PaperclipRuntimeSection,
|
|
} from "./settings/sections/RuntimesSections";
|
|
import { SecretsSection } from "./settings/sections/SecretsSection";
|
|
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 { CliBinarySection } from "./settings/sections/CliBinarySection";
|
|
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";
|
|
import { VoiceInputSection } from "./settings/sections/VoiceInputSection";
|
|
import { ProjectMcpSection } from "./settings/sections/ProjectMcpSection";
|
|
import { BackupsSection } from "./settings/sections/BackupsSection";
|
|
import { ConfigurationVersionsSection } from "./settings/sections/ConfigurationVersionsSection";
|
|
import { DatabaseBackupsSection } from "./settings/sections/DatabaseBackupsSection";
|
|
import { LoadingSpinner } from "./LoadingSpinner";
|
|
import { PluginsSection } from "./settings/sections/PluginsSection";
|
|
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
|
import type { ToastType } from "../hooks/useToast";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
|
|
import "./SettingsModal.css";
|
|
import { FileBrowser } from "./FileBrowser";
|
|
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
|
import { FloatingWindow } from "./FloatingWindow";
|
|
import { ProviderIcon } from "./ProviderIcon";
|
|
import { generateUniquePresetId } from "../utils/modelPresets";
|
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
|
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
|
|
import { openExternalUrl } from "../utils/open-external";
|
|
import { useConfirm } from "../hooks/useConfirm";
|
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
|
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
|
import { useNodes } from "../hooks/useNodes";
|
|
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";
|
|
import { subscribeSse } from "../sse-bus";
|
|
import { SETTINGS_SECTION_METADATA } from "../../src/shared/settings-sections";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub star count — cached locally and refreshed only while Settings is visible.
|
|
// ---------------------------------------------------------------------------
|
|
export const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
|
|
export const GITHUB_STAR_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
|
|
|
|
function isSlashPrefixedAbsolutePath(path: string): boolean {
|
|
return path.startsWith("/");
|
|
}
|
|
|
|
function DiscordIcon({ size = 13 }: { size?: number }) {
|
|
return (
|
|
<svg
|
|
data-testid="discord-icon"
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
role="img"
|
|
aria-hidden="true"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M20.317 4.369A19.791 19.791 0 0 0 15.885 3a13.66 13.66 0 0 0-.696 1.412 18.27 18.27 0 0 0-6.378 0A13.627 13.627 0 0 0 8.115 3a19.736 19.736 0 0 0-4.432 1.369C.878 8.604.111 12.734.494 16.803a19.916 19.916 0 0 0 5.993 3.048 14.43 14.43 0 0 0 1.286-2.106 12.94 12.94 0 0 1-2.024-.977c.17-.122.337-.249.499-.381 3.908 1.838 8.149 1.838 12.01 0 .163.132.329.259.5.381a12.936 12.936 0 0 1-2.028.978 14.344 14.344 0 0 0 1.287 2.105 19.85 19.85 0 0 0 5.996-3.049c.449-4.713-.766-8.806-3.696-12.433zM8.02 14.335c-1.184 0-2.157-1.085-2.157-2.419 0-1.334.95-2.418 2.157-2.418 1.217 0 2.167 1.095 2.157 2.418 0 1.334-.95 2.419-2.157 2.419zm7.975 0c-1.184 0-2.157-1.085-2.157-2.419 0-1.334.95-2.418 2.157-2.418 1.217 0 2.167 1.095 2.157 2.418 0 1.334-.94 2.419-2.157 2.419z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function toTrackingRepoOptions(remotes: GitRemote[]): TrackingRepoOption[] {
|
|
const byValue = new Map<string, TrackingRepoOption>();
|
|
for (const remote of remotes) {
|
|
const value = `${remote.owner}/${remote.repo}`;
|
|
if (!byValue.has(value)) {
|
|
byValue.set(value, { value, label: value });
|
|
}
|
|
}
|
|
return [...byValue.values()].sort((a, b) => a.value.localeCompare(b.value));
|
|
}
|
|
|
|
/**
|
|
* Has the user already clicked the "Star on GitHub" button at any point in
|
|
* the past? Used to permanently hide the button afterward — clicking opens
|
|
* the repo where the actual star happens, so we treat that click as intent
|
|
* to star and stop nagging.
|
|
*/
|
|
function useStarClickedFlag(): [boolean, () => void] {
|
|
const [clicked, setClicked] = useState<boolean>(() => {
|
|
try {
|
|
return localStorage.getItem(GITHUB_STAR_CLICKED_KEY) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const markClicked = useCallback(() => {
|
|
setClicked(true);
|
|
try {
|
|
localStorage.setItem(GITHUB_STAR_CLICKED_KEY, "true");
|
|
} catch {
|
|
// quota / private mode — best-effort
|
|
}
|
|
}, []);
|
|
return [clicked, markClicked];
|
|
}
|
|
|
|
interface StarCache {
|
|
count: number;
|
|
fetchedAt: number;
|
|
}
|
|
|
|
function readGitHubStarCache(): StarCache | null {
|
|
try {
|
|
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
|
|
if (!raw) return null;
|
|
|
|
const parsed: StarCache = JSON.parse(raw) as StarCache;
|
|
return Number.isFinite(parsed.count) && Number.isFinite(parsed.fetchedAt) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isGitHubStarCacheFresh(cache: StarCache | null): boolean {
|
|
return cache !== null && Date.now() - cache.fetchedAt < GITHUB_STAR_CACHE_TTL_MS;
|
|
}
|
|
|
|
/**
|
|
* FNXC:SettingsGitHubStar 2026-07-16-07:47:
|
|
* Keep the Settings star counter current with a visibility-gated, bounded interval refresh.
|
|
* The hook never fetches while its interface is off-screen, disables HTTP caching to avoid stale
|
|
* GitHub responses, and shares an in-flight guard so foreground and interval triggers cannot flood the network.
|
|
*/
|
|
export function useGitHubStarCount(): number | null {
|
|
const [count, setCount] = useState<number | null>(() => readGitHubStarCache()?.count ?? null);
|
|
const inFlightRef = useRef(false);
|
|
|
|
const refresh = useCallback(() => {
|
|
if (document.hidden || inFlightRef.current || isGitHubStarCacheFresh(readGitHubStarCache())) {
|
|
return;
|
|
}
|
|
|
|
inFlightRef.current = true;
|
|
void fetch("https://api.github.com/repos/Runfusion/Fusion", { cache: "no-store" })
|
|
.then(async (response) => {
|
|
if (!response.ok) return null;
|
|
const data = await response.json() as { stargazers_count?: unknown };
|
|
return typeof data.stargazers_count === "number" && Number.isFinite(data.stargazers_count)
|
|
? data.stargazers_count
|
|
: null;
|
|
})
|
|
.then((starCount) => {
|
|
if (starCount === null) return;
|
|
|
|
const cache: StarCache = { count: starCount, fetchedAt: Date.now() };
|
|
try {
|
|
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
|
|
} catch {
|
|
// quota exceeded — preserve the displayed value even when persistence is unavailable
|
|
}
|
|
setCount(starCount);
|
|
})
|
|
.catch(() => {
|
|
// Keep the last known value and stale cache so the next eligible trigger can retry.
|
|
})
|
|
.finally(() => {
|
|
inFlightRef.current = false;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
|
|
const onVisibilityChange = () => {
|
|
if (!document.hidden) refresh();
|
|
};
|
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
const intervalId = window.setInterval(refresh, GITHUB_STAR_CACHE_TTL_MS);
|
|
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
window.clearInterval(intervalId);
|
|
};
|
|
}, [refresh]);
|
|
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Settings sections configuration.
|
|
*
|
|
* Each section groups related settings fields under a sidebar nav item.
|
|
* Sections have a `scope` to indicate where their settings are stored:
|
|
* - "global": User-level settings stored in ~/.fusion/settings.json (shared across projects)
|
|
* - "project": Project-specific settings stored in .fusion/config.json
|
|
* - undefined: Section operates independently of settings storage (e.g. authentication)
|
|
*
|
|
* Group headers (isGroupHeader: true) are non-clickable labels that visually group sections.
|
|
* The sidebar is organized into three groups:
|
|
* - Global: User-level/shared sections (global-general, authentication, appearance,
|
|
* notifications, node-sync, global-models)
|
|
* - Runtimes: Global-scoped plugin runtime sections (hermes-runtime, openclaw-runtime,
|
|
* paperclip-runtime)
|
|
* - Project: Project-scoped sections (project-models, general, scheduling, node-routing,
|
|
* worktrees, commands, merge, memory, experimental, prompts, backups, plugins)
|
|
*
|
|
* To add a new section:
|
|
* 1. Add an entry to SETTINGS_SECTIONS with a unique id, label, and scope
|
|
* 2. Add a corresponding case in renderSectionFields()
|
|
*/
|
|
/** Section entry type with optional icon */
|
|
export type SettingsSection = {
|
|
id: string;
|
|
label: string;
|
|
labelKey: string;
|
|
scope: "global" | "project" | undefined;
|
|
icon?: typeof Globe;
|
|
isGroupHeader?: boolean;
|
|
searchableText?: string[];
|
|
searchableKeys?: string[];
|
|
};
|
|
|
|
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;
|
|
const SETTINGS_NAV_MAX_WIDTH = 420;
|
|
|
|
/*
|
|
FNXC:SettingsSimplification 2026-07-10-23:24:
|
|
Settings opens in a focused mode that omits specialist integration, runtime, diagnostics, and infrastructure sections. The Advanced settings switch restores every section, applies consistently to desktop navigation, mobile navigation, and search, and persists only as a browser-local display preference so it never changes or exports project settings.
|
|
|
|
FNXC:SettingsNavigation 2026-07-16-12:00:
|
|
FN-8128 returns CLI Binary to the default Settings view. It is deliberately absent from this Advanced-only set so desktop navigation, the mobile picker, and search expose binary install and diagnostic controls without requiring the browser-local Advanced preference.
|
|
*/
|
|
const RUNTIME_PLUGIN_SECTION_IDS: ReadonlyMap<string, string> = new Map([
|
|
["fusion-plugin-hermes-runtime", "hermes-runtime"],
|
|
["fusion-plugin-openclaw-runtime", "openclaw-runtime"],
|
|
["fusion-plugin-paperclip-runtime", "paperclip-runtime"],
|
|
] as const);
|
|
|
|
const RUNTIME_SETTINGS_SECTION_IDS = new Set(RUNTIME_PLUGIN_SECTION_IDS.values());
|
|
|
|
export const ADVANCED_SETTINGS_SECTION_IDS = new Set(
|
|
SETTINGS_SECTION_METADATA.filter((section) => section.advanced).map((section) => section.id),
|
|
);
|
|
|
|
function readAdvancedSettingsPreference(): boolean {
|
|
try {
|
|
return localStorage.getItem(ADVANCED_SETTINGS_STORAGE_KEY) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function clampSettingsNavWidth(width: number): number {
|
|
if (!Number.isFinite(width)) return SETTINGS_NAV_DEFAULT_WIDTH;
|
|
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, Math.round(width)));
|
|
}
|
|
|
|
function readSettingsNavWidthPreference(): number {
|
|
try {
|
|
const stored = Number.parseFloat(localStorage.getItem(SETTINGS_NAV_WIDTH_STORAGE_KEY) ?? "");
|
|
return clampSettingsNavWidth(stored);
|
|
} catch {
|
|
return SETTINGS_NAV_DEFAULT_WIDTH;
|
|
}
|
|
}
|
|
|
|
function removeEmptySettingsGroups(sections: SettingsSection[]): SettingsSection[] {
|
|
return sections.filter((section, index) => {
|
|
if (!section.isGroupHeader) return true;
|
|
for (const candidate of sections.slice(index + 1)) {
|
|
if (candidate.isGroupHeader) return false;
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
export function normalizeSettingsSearchText(value: string): string {
|
|
return value.trim().toLocaleLowerCase();
|
|
}
|
|
|
|
export function sectionMatchesSettingsSearch(
|
|
section: SettingsSection,
|
|
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 ?? []),
|
|
...(section.searchableKeys ?? []).map((key) => translateSearchKey(key)),
|
|
]
|
|
.map(normalizeSettingsSearchText)
|
|
.some((candidate) => candidate.includes(query));
|
|
}
|
|
|
|
export function filterSettingsSectionsForSearch(
|
|
sections: SettingsSection[],
|
|
query: string,
|
|
translateLabel: (section: SettingsSection) => string,
|
|
translateSearchKey: (key: string) => string,
|
|
entryMatchedSectionIds?: ReadonlySet<string>,
|
|
): SettingsSection[] {
|
|
if (!query) {
|
|
return sections;
|
|
}
|
|
|
|
const matchedIds = new Set(
|
|
sections
|
|
.filter((section) => !section.isGroupHeader && sectionMatchesSettingsSearch(section, query, translateLabel(section), translateSearchKey, entryMatchedSectionIds))
|
|
.map((section) => section.id),
|
|
);
|
|
|
|
return sections.filter((section, index) => {
|
|
if (!section.isGroupHeader) {
|
|
return matchedIds.has(section.id);
|
|
}
|
|
|
|
for (const candidate of sections.slice(index + 1)) {
|
|
if (candidate.isGroupHeader) {
|
|
return false;
|
|
}
|
|
if (matchedIds.has(candidate.id)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function resolveFirstSelectableSettingsSection(sections: SettingsSection[], fallback: string): string {
|
|
return sections.find((section) => !section.isGroupHeader)?.id ?? fallback;
|
|
}
|
|
|
|
|
|
/*
|
|
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.
|
|
*/
|
|
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 {
|
|
// 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 `${label} · Project`;
|
|
}
|
|
return label;
|
|
}
|
|
|
|
function resolveMaxAutoMergeRetriesForSettingsForm(settings?: { maxAutoMergeRetries?: unknown } | null): number {
|
|
const configured = Number(settings?.maxAutoMergeRetries);
|
|
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3;
|
|
}
|
|
|
|
/** FNXC:ExecutorToolFailureRetry 2026-07-16-12:00: zero is an intentional project setting that disables retries/backoff, so do not use a truthy fallback when normalizing the form. */
|
|
function resolveNonNegativeExecutorToolFailureSetting(value: unknown, fallback: number): number {
|
|
const configured = Number(value);
|
|
return Number.isFinite(configured) && configured >= 0 ? Math.floor(configured) : fallback;
|
|
}
|
|
|
|
const SETTINGS_SECTION_ICONS: Readonly<Record<string, typeof Globe>> = {
|
|
authentication: Globe,
|
|
"source-control-global": GitBranch,
|
|
"source-control": GitBranch,
|
|
};
|
|
|
|
export const SETTINGS_SECTIONS: SettingsSection[] = SETTINGS_SECTION_METADATA.map((section) => ({
|
|
id: section.id,
|
|
label: section.label,
|
|
labelKey: section.labelKey,
|
|
scope: section.scope,
|
|
...(SETTINGS_SECTION_ICONS[section.id] ? { icon: SETTINGS_SECTION_ICONS[section.id] } : {}),
|
|
...(section.isGroupHeader ? { isGroupHeader: true } : {}),
|
|
...(section.searchableText ? { searchableText: [...section.searchableText] } : {}),
|
|
...(section.searchableKeys ? { searchableKeys: [...section.searchableKeys] } : {}),
|
|
}));
|
|
|
|
// 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).
|
|
|
|
/** Well-known experimental feature flags with display labels.
|
|
* These always appear in the Experimental Features settings tab,
|
|
* regardless of whether they exist in the project's settings blob.
|
|
* IMPORTANT: Dev Server is canonically keyed by `devServerView`; `devServer`
|
|
* is treated as a legacy alias and must never render as a second row. */
|
|
const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
|
insights: "Insights",
|
|
memoryView: "Memory Editor",
|
|
skillsView: "Skills View",
|
|
nodesView: "Nodes View",
|
|
devServerView: "Dev Server",
|
|
todoView: "Todo List",
|
|
researchView: "Research View",
|
|
evalsView: "Evals View",
|
|
/*
|
|
FNXC:SettingsExperimental 2026-08-01-00:00:
|
|
FN-8352 promotes Ideation to a default-off top-level view. Keep its toggle
|
|
visible so operators explicitly opt into the sidebar and mobile More surface.
|
|
*/
|
|
ideationView: "Ideation View",
|
|
qualityPlugin: "Quality Plugin",
|
|
goalsView: "Goals View",
|
|
/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: The AI subtask-breakdown quick-add affordance is exposed only through this default-off experimental flag so missing settings keep every quick-add Subtask button hidden. */
|
|
subtaskBreakdown: "Subtask Breakdown",
|
|
leftSidebarNav: "Left Sidebar Navigation",
|
|
sandbox: "Sandbox (command isolation)",
|
|
chatRooms: "Chat Rooms",
|
|
agentOnboarding: "Planning-style Agent Onboarding",
|
|
workflowInterpreterDualObserve: "Workflow Graph Engine — dual-observe parity (diagnostic)",
|
|
};
|
|
|
|
/*
|
|
FNXC:SettingsExperimental 2026-06-22-17:55:
|
|
Workflow rollout diagnostics remain supported in persisted settings and engine code, but they are no longer normal user-facing Experimental toggles. Hide dual-observe from the settings list so operators do not accidentally flip runtime diagnostic switches from the product UI.
|
|
|
|
FNXC:SettingsExperimental 2026-06-22-18:00:
|
|
workflowGraphExecutor and workflowColumns graduated from Experimental. They are intentionally absent from the known-label registry, but remain in the hidden registry so stale persisted values never render as resurrected unknown settings while runtime code ignores them.
|
|
|
|
FNXC:SettingsExperimental 2026-06-22-18:50:
|
|
The Roadmaps dashboard view and experiment were removed from the product surface. Hide stale persisted `roadmap` values so Settings does not expose a dead toggle.
|
|
|
|
FNXC:SettingsExperimental 2026-06-22-18:00:
|
|
Right Dock Panel is no longer experimental: keep honoring the dock as always-on in App, but hide any stale persisted `rightDock` setting from the Experimental list.
|
|
|
|
FNXC:SettingsExperimental 2026-06-23-01:31:
|
|
Chat Rooms, Goals, Memory, Insights, Skills, and Todo graduated from Experimental. Hide stale persisted flags so users cannot accidentally disable now-default dashboard surfaces during upgrades.
|
|
|
|
FNXC:SettingsExperimental 2026-06-26-00:00:
|
|
Remote Access graduated from Experimental — section is always available; stale persisted `remoteAccess` flags are hidden so upgrades cannot disable it.
|
|
*/
|
|
const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set<string>([
|
|
"chatRooms",
|
|
"goalsView",
|
|
"insights",
|
|
"memoryView",
|
|
"remoteAccess",
|
|
"roadmap",
|
|
"rightDock",
|
|
"skillsView",
|
|
"todoView",
|
|
"workflowColumns",
|
|
"workflowGraphExecutor",
|
|
"workflowInterpreterDualObserve",
|
|
]);
|
|
|
|
/*
|
|
FNXC:Navigation 2026-06-21-00:00:
|
|
The dashboard owns the left sidebar default-on rollout because the shared experimental-feature helper must keep default-off semantics for unrelated experiments. Keep this set local to Settings so toggle checked-state matches App's `leftSidebarNav !== false` derivation without changing core behavior.
|
|
|
|
FNXC:Navigation 2026-06-22-18:00:
|
|
Only Left Sidebar Navigation remains a default-on experimental toggle; right dock was promoted to always-on app chrome and is hidden from this settings surface.
|
|
*/
|
|
const DEFAULT_ON_EXPERIMENTAL_FEATURES = new Set<string>(["leftSidebarNav"]);
|
|
|
|
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
|
|
devServer: "devServerView",
|
|
};
|
|
|
|
function getCanonicalExperimentalFeatureKey(key: string): string {
|
|
return EXPERIMENTAL_FEATURE_LEGACY_ALIASES[key] ?? key;
|
|
}
|
|
function isExperimentalFeatureEnabled(features: Record<string, boolean>, key: string): boolean {
|
|
if (features[key] === true) return true;
|
|
if (features[key] === false) return false;
|
|
if (Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES).some(([legacyKey, canonicalKey]) => canonicalKey === key && features[legacyKey] === true)) return true;
|
|
return DEFAULT_ON_EXPERIMENTAL_FEATURES.has(key);
|
|
}
|
|
|
|
function isDashboardExperimentalFeatureEnabled(features: Record<string, boolean>, key: string): boolean {
|
|
const canonicalKey = getCanonicalExperimentalFeatureKey(key);
|
|
if (DEFAULT_ON_EXPERIMENTAL_FEATURES.has(canonicalKey) && features[canonicalKey] === undefined) {
|
|
return true;
|
|
}
|
|
return isExperimentalFeatureEnabled(features, canonicalKey);
|
|
}
|
|
|
|
function normalizeExperimentalFeaturesForSave(features?: Record<string, boolean>): Record<string, boolean | null> {
|
|
if (!features) {
|
|
return {};
|
|
}
|
|
|
|
const normalized: Record<string, boolean | null> = {};
|
|
for (const [key, enabled] of Object.entries(features)) {
|
|
normalized[getCanonicalExperimentalFeatureKey(key)] = enabled;
|
|
}
|
|
|
|
for (const [legacyKey, canonicalKey] of Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES)) {
|
|
if (normalized[canonicalKey] !== undefined && !(legacyKey in normalized)) {
|
|
normalized[legacyKey] = null;
|
|
}
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeWorktreeCopyFilesForSave(paths?: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const normalized: string[] = [];
|
|
for (const rawPath of paths ?? []) {
|
|
const path = rawPath.trim();
|
|
if (!path || seen.has(path)) continue;
|
|
seen.add(path);
|
|
normalized.push(path);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
type LegacySectionId = "pi-extensions";
|
|
export type SectionId = SettingsSection["id"] | LegacySectionId;
|
|
|
|
/*
|
|
FNXC:SettingsNavigation 2026-07-16-01:00:
|
|
Authentication was the previous Settings landing section because a provider connection gates model configuration. It remains first within AI & Models, but no longer determines the modal default.
|
|
|
|
FNXC:SettingsNavigation 2026-07-16-13:40:
|
|
FN-8130 requires Settings to open on Appearance, the global Preferences section, when no explicit initialSection is supplied. Appearance is always visible — it is not behind the Advanced switch — so the default can never land on a section hidden from the operator's navigation.
|
|
*/
|
|
const DEFAULT_SETTINGS_SECTION: SectionId = "appearance";
|
|
|
|
type PluginsSubsectionId = "fusion-plugins" | "pi-extensions";
|
|
|
|
/** Local form state extends Settings with a worktreeInitCommand override and lets tokenCap carry null (delete semantic). */
|
|
type SettingsFormState = Settings & { worktreeInitCommand?: string; tokenCap?: number | null };
|
|
type GlobalSourceControlSettings = Pick<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType" | "reportRoadmapDedupeEnabled" | "reportRoadmapLabel" | "reportRoadmapRepo">;
|
|
|
|
interface SettingsModalProps {
|
|
onClose: () => void;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
projectId?: string;
|
|
/** Optional section to show when the modal first opens. Defaults to the global Appearance section. */
|
|
initialSection?: SectionId;
|
|
/** Current theme mode */
|
|
themeMode?: ThemeMode;
|
|
/** Current color theme */
|
|
colorTheme?: ColorTheme;
|
|
/** Called when theme mode changes */
|
|
onThemeModeChange?: (mode: ThemeMode) => void;
|
|
/** Called when color theme changes */
|
|
onColorThemeChange?: (theme: ColorTheme) => void;
|
|
/** Current dashboard font scale percentage */
|
|
dashboardFontScalePct?: number;
|
|
/** Current shadcn-custom color overrides */
|
|
shadcnCustomColors?: Record<string, string>;
|
|
/** Resolved theme mode for shadcn-custom defaults */
|
|
resolvedThemeMode?: "dark" | "light";
|
|
/** Called when dashboard font scale changes */
|
|
onDashboardFontScaleChange?: (scalePct: number) => void;
|
|
/** Called when shadcn-custom color overrides change */
|
|
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
|
|
/** Mirrors pending Quick Chat launcher changes into the app shell immediately. */
|
|
onQuickChatButtonModeChange?: (mode: "floating" | "footer" | "off") => void;
|
|
/** Mirrors pending mobile quick-action changes into the app shell immediately. */
|
|
onMobileNavPrimaryItemsChange?: (items: string[]) => void;
|
|
/** Optional callback when user wants to reopen the onboarding guide */
|
|
onReopenOnboarding?: () => void;
|
|
/** Optional callback to open approvals/mailbox view. */
|
|
onOpenApprovals?: (approvalId?: string) => void;
|
|
/**
|
|
* Closes this modal and opens the workflow node editor with its Settings panel
|
|
* pre-selected for the project's default workflow. Used by the moved-settings
|
|
* redirect stubs (U9 / KTD-5, R10). Optional so the modal renders standalone.
|
|
*/
|
|
onOpenWorkflowSettings?: () => void;
|
|
/*
|
|
FNXC:Settings 2026-06-22-00:00:
|
|
Settings renders both as a dialog overlay (presentation="modal", default) and as an embedded main-content view (presentation="embedded"). Embedded mode drops the fixed overlay backdrop and modal close button, fills the host pane, and disables modal-only behaviors (scroll lock, escape-to-close, resize-persist, overlay click-dismiss). The modal path is kept byte-identical for non-navigation callers (e.g. mobile/right-dock).
|
|
*/
|
|
presentation?: ModalPresentation;
|
|
}
|
|
|
|
/** Adapter descriptor served by GET /api/cli-agents (U15). */
|
|
interface CliAdapterDescriptorView {
|
|
id: string;
|
|
name: string;
|
|
tier: "native" | "hybrid" | "generic";
|
|
defaultCommand: string | null;
|
|
}
|
|
|
|
interface CliAgentSettingsEntry {
|
|
commandOverride?: string;
|
|
extraArgs?: string[];
|
|
autonomyMode?: "default" | "elevated";
|
|
envAdditions?: string[];
|
|
}
|
|
|
|
/**
|
|
* Per-adapter CLI-agent launch settings section (U15). Reads the adapter catalog
|
|
* + persisted settings + per-project autonomy approval state, and lets the
|
|
* operator edit command override / extra args / env additions / autonomy mode.
|
|
* Switching an adapter to elevated autonomy goes through an explicit
|
|
* confirmation flow before the per-project approval is granted.
|
|
*/
|
|
function CliAgentsSettingsSection({
|
|
projectId: _projectId,
|
|
addToast,
|
|
}: {
|
|
projectId?: string;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
}) {
|
|
const { t } = useTranslation("app");
|
|
const { confirm } = useConfirm();
|
|
const [adapters, setAdapters] = useState<CliAdapterDescriptorView[]>([]);
|
|
const [settings, setSettings] = useState<Record<string, CliAgentSettingsEntry>>({});
|
|
const [approved, setApproved] = useState<Record<string, boolean>>({});
|
|
const [selectedId, setSelectedId] = useState<string>("");
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const [catRes, setRes] = await Promise.all([
|
|
fetch("/api/cli-agents"),
|
|
fetch("/api/cli-agents/settings"),
|
|
]);
|
|
const cat = catRes.ok ? await catRes.json() : { adapters: [] };
|
|
const set = setRes.ok ? await setRes.json() : { cliAgents: {} };
|
|
if (cancelled) return;
|
|
const list = (cat.adapters ?? []) as CliAdapterDescriptorView[];
|
|
setAdapters(list);
|
|
setSettings((set.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
|
|
if (list.length > 0) setSelectedId((prev) => prev || list[0].id);
|
|
// Approval state is per-adapter; fetch lazily per selection below.
|
|
} catch {
|
|
// Non-fatal: render the static fallback list.
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!selectedId) return;
|
|
let cancelled = false;
|
|
fetch(`/api/cli-agents/${selectedId}/autonomy`)
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((data) => {
|
|
if (cancelled || !data) return;
|
|
setApproved((prev) => ({ ...prev, [selectedId]: Boolean(data.approved) }));
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [selectedId]);
|
|
|
|
const current = settings[selectedId] ?? {};
|
|
|
|
const persist = useCallback(
|
|
async (adapterId: string, config: CliAgentSettingsEntry) => {
|
|
try {
|
|
const res = await fetch("/api/cli-agents/settings", {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ adapterId, config }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
setSettings((data.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || t("settings.cliAgents.saveFailed"), "error");
|
|
}
|
|
},
|
|
[addToast, t],
|
|
);
|
|
|
|
const updateCurrent = useCallback(
|
|
(patch: Partial<CliAgentSettingsEntry>) => {
|
|
if (!selectedId) return;
|
|
const next = { ...current, ...patch };
|
|
setSettings((prev) => ({ ...prev, [selectedId]: next }));
|
|
void persist(selectedId, next);
|
|
},
|
|
[selectedId, current, persist],
|
|
);
|
|
|
|
const onAutonomyChange = useCallback(
|
|
async (mode: "default" | "elevated") => {
|
|
if (!selectedId) return;
|
|
if (mode === "elevated") {
|
|
const ok = await confirm({
|
|
title: t("settings.cliAgents.elevatedConfirmTitle"),
|
|
message: t("settings.cliAgents.elevatedConfirmBody"),
|
|
confirmLabel: t("settings.cliAgents.elevatedConfirmAction"),
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
// Record the per-project approval first, then persist the mode.
|
|
try {
|
|
const res = await fetch(`/api/cli-agents/${selectedId}/approve-autonomy`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: true }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
setApproved((prev) => ({ ...prev, [selectedId]: true }));
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || t("settings.cliAgents.approveFailed"), "error");
|
|
return;
|
|
}
|
|
}
|
|
updateCurrent({ autonomyMode: mode });
|
|
},
|
|
[selectedId, confirm, t, addToast, updateCurrent],
|
|
);
|
|
|
|
return (
|
|
<div data-testid="cli-agents-settings">
|
|
<h4 className="settings-section-heading">{t("settings.cliAgents.heading")}</h4>
|
|
<p className="settings-section-description">{t("settings.cliAgents.description")}</p>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="cliAgentAdapter">{t("settings.cliAgents.adapterLabel")}</label>
|
|
<select
|
|
id="cliAgentAdapter"
|
|
value={selectedId}
|
|
onChange={(e) => setSelectedId(e.target.value)}
|
|
>
|
|
{adapters.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.name} ({t(`settings.cliAgents.tier.${a.tier}`)})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{selectedId && (
|
|
<>
|
|
<div className="form-group">
|
|
<label htmlFor="cliAgentCommand">{t("settings.cliAgents.commandLabel")}</label>
|
|
<input
|
|
id="cliAgentCommand"
|
|
type="text"
|
|
placeholder={
|
|
adapters.find((a) => a.id === selectedId)?.defaultCommand ?? ""
|
|
}
|
|
value={current.commandOverride ?? ""}
|
|
onChange={(e) => updateCurrent({ commandOverride: e.target.value || undefined })}
|
|
/>
|
|
<p className="settings-field-help">{t("settings.cliAgents.commandHelp")}</p>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="cliAgentExtraArgs">{t("settings.cliAgents.extraArgsLabel")}</label>
|
|
<input
|
|
id="cliAgentExtraArgs"
|
|
type="text"
|
|
value={(current.extraArgs ?? []).join(" ")}
|
|
onChange={(e) =>
|
|
updateCurrent({
|
|
extraArgs: e.target.value.split(/\s+/).filter((s) => s.length > 0),
|
|
})
|
|
}
|
|
/>
|
|
<p className="settings-field-help">{t("settings.cliAgents.extraArgsHelp")}</p>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="cliAgentEnv">{t("settings.cliAgents.envLabel")}</label>
|
|
<input
|
|
id="cliAgentEnv"
|
|
type="text"
|
|
value={(current.envAdditions ?? []).join(", ")}
|
|
onChange={(e) =>
|
|
updateCurrent({
|
|
envAdditions: e.target.value
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0),
|
|
})
|
|
}
|
|
/>
|
|
<p className="settings-field-help">{t("settings.cliAgents.envHelp")}</p>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="cliAgentAutonomy">{t("settings.cliAgents.autonomyLabel")}</label>
|
|
<select
|
|
id="cliAgentAutonomy"
|
|
value={current.autonomyMode ?? "default"}
|
|
onChange={(e) => void onAutonomyChange(e.target.value as "default" | "elevated")}
|
|
>
|
|
<option value="default">{t("settings.cliAgents.autonomy.default")}</option>
|
|
<option value="elevated">{t("settings.cliAgents.autonomy.elevated")}</option>
|
|
</select>
|
|
<p className="settings-field-help">
|
|
{approved[selectedId]
|
|
? t("settings.cliAgents.approvedNote")
|
|
: t("settings.cliAgents.autonomyHelp")}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SettingsModal({
|
|
onClose,
|
|
addToast,
|
|
projectId,
|
|
initialSection,
|
|
/*
|
|
FNXC:DashboardTheming 2026-07-03-00:00:
|
|
Settings/onboarding surfaces that render before App threads persisted appearance settings should mirror fresh startup defaults: System mode with Shadcn Ember.
|
|
*/
|
|
themeMode = "system",
|
|
colorTheme = "shadcn-ember",
|
|
onThemeModeChange,
|
|
onColorThemeChange,
|
|
dashboardFontScalePct = 100,
|
|
shadcnCustomColors = {},
|
|
resolvedThemeMode,
|
|
onDashboardFontScaleChange,
|
|
onShadcnCustomColorsChange,
|
|
onQuickChatButtonModeChange,
|
|
onMobileNavPrimaryItemsChange,
|
|
onReopenOnboarding,
|
|
onOpenApprovals,
|
|
onOpenWorkflowSettings,
|
|
presentation = "modal",
|
|
}: SettingsModalProps) {
|
|
const { isEmbedded, scrollLockEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation);
|
|
const { t } = useTranslation("app");
|
|
const { confirm } = useConfirm();
|
|
const viewportMode = useViewportMode();
|
|
// Modal-only: lock background scroll on mobile. Embedded view owns its own scroll region.
|
|
useMobileScrollLock(scrollLockEnabled);
|
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
|
enabled: viewportMode === "mobile",
|
|
});
|
|
const keyboardStyle: CSSProperties = keyboardOpen
|
|
? ({
|
|
"--keyboard-overlap": `${keyboardOverlap}px`,
|
|
"--vv-offset-top": `${viewportOffsetTop}px`,
|
|
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
|
} as CSSProperties)
|
|
: {};
|
|
const settingsContentRef = useRef<HTMLDivElement>(null);
|
|
const workflowLaneSaverRef = useRef<SectionSaveHandler | null>(null);
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-03-01:00:
|
|
Workflow lane edits live outside the shared Settings form. Track their revision
|
|
alongside form dirtiness so Option 1 auto-save and every close path flush them
|
|
too; a completion only clears the revision it actually persisted.
|
|
*/
|
|
const workflowLaneRevisionRef = useRef(0);
|
|
const [workflowLanesDirty, setWorkflowLanesDirty] = useState(false);
|
|
const markWorkflowLanesDirty = useCallback(() => {
|
|
workflowLaneRevisionRef.current += 1;
|
|
setWorkflowLanesDirty(true);
|
|
}, []);
|
|
const registerWorkflowLaneSaver = useCallback((saver: SectionSaveHandler | null) => {
|
|
/*
|
|
FNXC:ProjectModelsWorkflowLanes 2026-07-14-09:07:
|
|
Project Models workflow lane edits are workflow setting-values, not normal project settings. Keep the latest saver registered across section unmounts so auto-save and close flushing retain project-scoped workflow overrides after navigation.
|
|
*/
|
|
if (saver) {
|
|
workflowLaneSaverRef.current = saver;
|
|
}
|
|
}, []);
|
|
// FNXC:ModalTouchGeometry 2026-07-26-14:10: FloatingWindow owns movable, clamped geometry for the modal branch; the embedded Settings view remains an inline, chrome-free destination.
|
|
const sessionBannersHidden = useSessionBannersHidden();
|
|
const [form, setForm] = useState<SettingsFormState>({
|
|
maxConcurrent: 2,
|
|
maxConcurrentVerifications: 1,
|
|
maxWorktrees: 4,
|
|
// FNXC:CapacityModel 2026-07-28-13:20: worktrees are a capacity dimension by
|
|
// default; off means capacity is total agents only.
|
|
worktreeLimitEnabled: true,
|
|
pollIntervalMs: 15000,
|
|
heartbeatMultiplier: 1,
|
|
groupOverlappingFiles: true,
|
|
ignoreHiddenOverlapPaths: true,
|
|
overlapIgnorePaths: [],
|
|
allowAbsoluteFileBrowserPaths: false,
|
|
autoMerge: true,
|
|
// FNXC:PlanApproval 2026-07-04-00:00: FN-7557: local fallback mirrors DEFAULT_PROJECT_SETTINGS — auto-approve-all is now the default project posture.
|
|
planApprovalMode: "auto-approve-all",
|
|
mergeStrategy: "direct",
|
|
maxAutoMergeRetries: 3,
|
|
executorToolFailureRetryCount: 2,
|
|
executorToolFailureRetryBackoffMs: 2000,
|
|
executorToolFailureThreshold: 3,
|
|
executorModelEscalationEnabled: false,
|
|
executorEscalationProvider: "",
|
|
executorEscalationModelId: "",
|
|
executorEscalationNodeId: "",
|
|
mergeIntegrationWorktree: "reuse-task-worktree",
|
|
mergeAdvanceAutoSync: "stash-and-ff",
|
|
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true },
|
|
recycleWorktrees: false,
|
|
showWorktreeGrouping: false,
|
|
openTasksInRightSidebar: false,
|
|
openMobileTasksInPopup: false,
|
|
taskPopupsBoardListOnly: true,
|
|
showCostBadgeOnCards: false,
|
|
taskDetailChatFirst: false,
|
|
executorAllowSiblingBranchRename: false,
|
|
worktreeNaming: "random",
|
|
worktreeCopyFiles: [],
|
|
worktreesDir: "",
|
|
worktrunk: {
|
|
enabled: false,
|
|
binaryPath: "",
|
|
onFailure: "fail",
|
|
},
|
|
includeTaskIdInCommit: true,
|
|
worktreeInitCommand: "",
|
|
ntfyEnabled: false,
|
|
agentClarificationEnabled: false,
|
|
ntfyTopic: undefined,
|
|
ntfyAccessToken: undefined,
|
|
failureNotificationMode: "sticky-only",
|
|
failureNotificationDelayMs: 30000,
|
|
webhookEnabled: false,
|
|
webhookUrl: undefined,
|
|
webhookFormat: "generic",
|
|
webhookEvents: undefined,
|
|
githubLinkImportedIssuesToTracking: false,
|
|
prTitlePromptInstructions: "",
|
|
prDescriptionPromptInstructions: "",
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
// Guards the Save action against double-submit (rapid clicks / Enter) while the
|
|
// parallel global+project writes are in flight.
|
|
const [, setIsSaving] = useState(false);
|
|
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [autoSaveReady, setAutoSaveReady] = useState(false);
|
|
const autoSaveActivationSnapshotRef = useRef<string | null>(null);
|
|
const persistInFlightRef = useRef(false);
|
|
const trailingPersistRef = useRef(false);
|
|
const lastPersistSucceededRef = useRef(true);
|
|
const persistSettingsRef = useRef<(() => Promise<boolean>) | null>(null);
|
|
const latestAutoSaveStateRef = useRef({ dirty: false, changed: false });
|
|
const requestSectionChangeRef = useRef<((sectionId: SectionId) => void) | null>(null);
|
|
// Track initial values to detect explicit clears for null-as-delete semantics
|
|
const [initialValues, setInitialValues] = useState<Settings | null>(null);
|
|
// Track scoped settings for inheritance detection (fetched alongside merged settings)
|
|
// This stores the raw { global, project } structure from the API
|
|
const [scopedSettings, setScopedSettings] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
|
|
const [globalGitlabSettings, setGlobalGitlabSettings] = useState<GlobalSourceControlSettings | null>(null);
|
|
// Track initial scoped values for null-as-delete semantics on project overrides
|
|
const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
|
|
const mcpFormForScope = useCallback((scope: McpSettingsScope): Settings => ({
|
|
...form,
|
|
mcpServers: resolveScopedMcpSettings(scope, scopedSettings),
|
|
}), [form, scopedSettings]);
|
|
const setMcpFormForScope = useCallback((scope: McpSettingsScope): Dispatch<SetStateAction<Settings>> => (update) => {
|
|
setScopedSettings((current) => {
|
|
if (!current) return current;
|
|
const currentForm = {
|
|
...form,
|
|
mcpServers: resolveScopedMcpSettings(scope, current),
|
|
};
|
|
const nextForm = typeof update === "function" ? update(currentForm) : update;
|
|
if (scope === "global") {
|
|
return {
|
|
...current,
|
|
global: { ...current.global, mcpServers: nextForm.mcpServers },
|
|
};
|
|
}
|
|
return {
|
|
...current,
|
|
project: { ...current.project, mcpServers: nextForm.mcpServers },
|
|
};
|
|
});
|
|
}, [form]);
|
|
// Find the first non-group-header section for visibility fallback handling
|
|
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
|
|
const [installedRuntimeSectionIds, setInstalledRuntimeSectionIds] = useState<Set<string>>(() => new Set());
|
|
const [runtimeVisibilitySettled, setRuntimeVisibilitySettled] = useState(false);
|
|
const runtimeVisibilityRequestRef = useRef(0);
|
|
const refreshInstalledRuntimeSections = useCallback(async () => {
|
|
/*
|
|
FNXC:SettingsRuntimeNavigation 2026-07-22-12:00:
|
|
Runtime settings pages are backed only by installed plugin records, not the built-in catalog or runtime binary detection. Disabled installed records remain navigable so operators can inspect and re-enable them.
|
|
Every refresh clears visibility first: loading, undefined lists, and errors fail closed until a successful project-scoped GET /plugins response restores precisely its deduplicated runtime records.
|
|
*/
|
|
const requestId = ++runtimeVisibilityRequestRef.current;
|
|
setRuntimeVisibilitySettled(false);
|
|
setInstalledRuntimeSectionIds(new Set());
|
|
try {
|
|
const plugins = await fetchPlugins(projectId);
|
|
const next = new Set<string>();
|
|
for (const plugin of plugins ?? []) {
|
|
const sectionId = RUNTIME_PLUGIN_SECTION_IDS.get(plugin.id);
|
|
if (sectionId) next.add(sectionId);
|
|
}
|
|
if (requestId !== runtimeVisibilityRequestRef.current) return;
|
|
setInstalledRuntimeSectionIds(next);
|
|
setRuntimeVisibilitySettled(true);
|
|
} catch {
|
|
// Fail closed; a later successful lifecycle/reconnect refresh restores entries.
|
|
if (requestId !== runtimeVisibilityRequestRef.current) return;
|
|
setInstalledRuntimeSectionIds(new Set());
|
|
setRuntimeVisibilitySettled(true);
|
|
}
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
void refreshInstalledRuntimeSections();
|
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
|
return subscribeSse(`/api/events${query}`, {
|
|
events: {
|
|
"plugin:lifecycle": (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.data) as { scope?: string; projectId?: string };
|
|
if (payload.scope === "project" && (payload.projectId ?? projectId) !== projectId) return;
|
|
void refreshInstalledRuntimeSections();
|
|
} catch {
|
|
// Ignore malformed lifecycle data; reconnect still re-syncs authoritative state.
|
|
}
|
|
},
|
|
},
|
|
onReconnect: () => void refreshInstalledRuntimeSections(),
|
|
});
|
|
}, [projectId, refreshInstalledRuntimeSections]);
|
|
|
|
const [activeSection, setActiveSection] = useState<SectionId>(() => {
|
|
if (initialSection === "pi-extensions") {
|
|
return "plugins";
|
|
}
|
|
return initialSection ?? DEFAULT_SETTINGS_SECTION;
|
|
});
|
|
/*
|
|
FNXC:WindowsTerminalStartup 2026-07-04-06:30:
|
|
Do NOT auto-probe worktrunk status on a plain Settings/dashboard mount — on Windows the
|
|
status route resolves `wt`, which is Windows Terminal, and would pop its version dialog
|
|
(field report Issue 4; the engine probeWorktrunk guard is the backstop). Probe only when
|
|
the user is actually viewing the Worktrees section (a deliberate navigation) or worktrunk
|
|
is already enabled. Gating on `enabled` alone would deadlock: the enable toggle is disabled
|
|
until status === "installed", but status would never be fetched until enabled.
|
|
*/
|
|
const worktrunkInstall = useWorktrunkInstallStatus(projectId, {
|
|
enabled: activeSection === "worktrees" || form.worktrunk?.enabled === true,
|
|
});
|
|
const worktrunkInstallVerified = worktrunkInstall.status === "installed";
|
|
// Deterministic default: opening Plugins starts on Fusion Plugins unless legacy
|
|
// `initialSection="pi-extensions"` is explicitly provided.
|
|
const [activePluginsSubsection, setActivePluginsSubsection] = useState<PluginsSubsectionId>(() =>
|
|
initialSection === "pi-extensions" ? "pi-extensions" : "fusion-plugins",
|
|
);
|
|
/*
|
|
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);
|
|
/** Set when a search result drives the next section change; see the scroll-to-top effect. */
|
|
const settingsJumpPendingRef = useRef(false);
|
|
const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() =>
|
|
viewportMode === "mobile" ||
|
|
(typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
|
|
: false),
|
|
);
|
|
/**
|
|
* FNXC:Settings 2026-07-11-18:52:
|
|
* FN-7825 makes the desktop/tablet Settings rail resizable and persists the chosen width locally. Mobile remains stacked and ignores this inline CSS variable so a desktop-saved width cannot leak into the top-bar layout.
|
|
*/
|
|
const [settingsNavWidth, setSettingsNavWidth] = useState(() => readSettingsNavWidthPreference());
|
|
const settingsNavDragRef = useRef<{ startX: number; startWidth: number; previousUserSelect: string } | null>(null);
|
|
const [settingsSearchQuery, setSettingsSearchQuery] = useState("");
|
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(() => {
|
|
const requestedSection = initialSection === "pi-extensions" ? "plugins" : initialSection;
|
|
/*
|
|
FNXC:SettingsSimplification 2026-07-10-23:24:
|
|
Product links that intentionally open a specific advanced section must remain usable. Reveal advanced navigation for that Settings session, but do not persist the implicit reveal; only a direct user toggle changes the local-storage preference.
|
|
*/
|
|
return readAdvancedSettingsPreference() || (requestedSection !== undefined && ADVANCED_SETTINGS_SECTION_IDS.has(requestedSection));
|
|
});
|
|
const handleAdvancedSettingsChange = useCallback((enabled: boolean) => {
|
|
setShowAdvancedSettings(enabled);
|
|
try {
|
|
localStorage.setItem(ADVANCED_SETTINGS_STORAGE_KEY, String(enabled));
|
|
} catch {
|
|
// Storage can be unavailable in private/locked-down browser contexts; the in-session preference still works.
|
|
}
|
|
}, []);
|
|
const persistSettingsNavWidth = useCallback((width: number) => {
|
|
const nextWidth = clampSettingsNavWidth(width);
|
|
setSettingsNavWidth(nextWidth);
|
|
try {
|
|
localStorage.setItem(SETTINGS_NAV_WIDTH_STORAGE_KEY, String(nextWidth));
|
|
} catch {
|
|
// Storage can be unavailable in private/locked-down browser contexts; the in-session width still works.
|
|
}
|
|
return nextWidth;
|
|
}, []);
|
|
/*
|
|
* FNXC:Settings 2026-07-09-00:00:
|
|
* Mobile Settings previously always rendered the `.settings-search` row (label + input + result
|
|
* count) above the section picker, eating vertical space even when the user was not searching.
|
|
* On mobile only, the row now starts COLLAPSED behind a compact icon toggle; tapping it reveals
|
|
* the input, tapping again hides it. Desktop/tablet is untouched — the row is always visible
|
|
* there and the toggle never renders (see `isMobileSettingsSearch` below). Decision on the
|
|
* active-query edge case: `settingsSearchQuery` state is never cleared by collapsing, so
|
|
* re-expanding always restores the exact query and result count the user had before collapsing
|
|
* (no forced auto-expand — the toggle stays user-controlled).
|
|
*/
|
|
const [mobileSearchRowExpanded, setMobileSearchRowExpanded] = useState(false);
|
|
const [appVersion, setAppVersion] = useState<string | null>(null);
|
|
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
|
|
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
|
const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
|
|
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null);
|
|
const [restartSupported, setRestartSupported] = useState<boolean | undefined>();
|
|
const [restartLoading, setRestartLoading] = useState(false);
|
|
const [restartScheduled, setRestartScheduled] = useState(false);
|
|
const [restartError, setRestartError] = useState<string | null>(null);
|
|
const gitHubStarCount = useGitHubStarCount();
|
|
const [starClicked, markStarClicked] = useStarClickedFlag();
|
|
const [prefixError, setPrefixError] = useState<string | null>(null);
|
|
const [researchLimitError, setResearchLimitError] = useState<string | null>(null);
|
|
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
|
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
|
|
const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState<number | null>(null);
|
|
|
|
/*
|
|
FNXC:SettingsReset 2026-07-04-00:20:
|
|
Reset Settings confirmation dialog state (FN-7506). `resetInFlight` guards both
|
|
destructive actions against double-submit while the reset write + form refresh
|
|
are in progress, mirroring the `isSaving` guard on the Save action.
|
|
*/
|
|
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
|
const [resetInFlight, setResetInFlight] = useState(false);
|
|
|
|
const {
|
|
entries: overlapPathPickerEntries,
|
|
currentPath: overlapPathPickerCurrentPath,
|
|
setPath: setOverlapPathPickerPath,
|
|
loading: overlapPathPickerLoading,
|
|
error: overlapPathPickerError,
|
|
refresh: refreshOverlapPathPicker,
|
|
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId, { allowAbsolutePaths: false });
|
|
|
|
const {
|
|
entries: worktreesDirPickerEntries,
|
|
currentPath: worktreesDirPickerCurrentPath,
|
|
setPath: setWorktreesDirPickerPath,
|
|
loading: worktreesDirPickerLoading,
|
|
error: worktreesDirPickerError,
|
|
refresh: refreshWorktreesDirPicker,
|
|
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: false });
|
|
|
|
const {
|
|
entries: worktreeCopyFilePickerEntries,
|
|
currentPath: worktreeCopyFilePickerCurrentPath,
|
|
setPath: setWorktreeCopyFilePickerPath,
|
|
loading: worktreeCopyFilePickerLoading,
|
|
error: worktreeCopyFilePickerError,
|
|
refresh: refreshWorktreeCopyFilePicker,
|
|
} = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId, { allowAbsolutePaths: false });
|
|
|
|
const { nodes } = useNodes();
|
|
const experimentalFeatures = form.experimentalFeatures ?? {};
|
|
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
|
const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView");
|
|
const visibleSections = useMemo(() => removeEmptySettingsGroups(SETTINGS_SECTIONS.filter((section) => {
|
|
if (!showAdvancedSettings && ADVANCED_SETTINGS_SECTION_IDS.has(section.id)) {
|
|
return false;
|
|
}
|
|
|
|
if (RUNTIME_SETTINGS_SECTION_IDS.has(section.id) && !installedRuntimeSectionIds.has(section.id)) {
|
|
return false;
|
|
}
|
|
|
|
if (section.id === "research-global" || section.id === "research-project") {
|
|
return researchViewEnabled;
|
|
}
|
|
|
|
if (section.id === "scheduled-evals") {
|
|
return evalsViewEnabled;
|
|
}
|
|
|
|
return true;
|
|
})), [researchViewEnabled, evalsViewEnabled, installedRuntimeSectionIds, showAdvancedSettings]);
|
|
const firstVisibleSectionId = visibleSections.some((section) => section.id === DEFAULT_SETTINGS_SECTION)
|
|
? DEFAULT_SETTINGS_SECTION
|
|
: resolveFirstSelectableSettingsSection(visibleSections, firstNonHeaderSection?.id ?? "general");
|
|
const normalizedSettingsSearchQuery = normalizeSettingsSearchText(settingsSearchQuery);
|
|
/*
|
|
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),
|
|
entryMatchedSectionIds,
|
|
), [normalizedSettingsSearchQuery, t, visibleSections, entryMatchedSectionIds]);
|
|
const searchableSectionOptions = searchMatchedSections.filter((section) => !section.isGroupHeader);
|
|
const hasSettingsSearchQuery = normalizedSettingsSearchQuery.length > 0;
|
|
const hasSettingsSearchResults = searchableSectionOptions.length > 0;
|
|
// FNXC:Settings 2026-07-09-00:00: desktop/tablet always show the search row and never render the
|
|
// toggle; mobile starts collapsed (`mobileSearchRowExpanded === false`) until the user taps it.
|
|
const isMobileSettingsSearch = viewportMode === "mobile";
|
|
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) => {
|
|
// Claims the upcoming section change so the scroll-to-top below yields to the
|
|
// jump; otherwise the row we just scrolled to would be scrolled away from.
|
|
settingsJumpPendingRef.current = true;
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-03-00:00:
|
|
Search navigation is an operator-initiated section change too. Route it
|
|
through the same flush path as sidebar/mobile navigation so a pending edit
|
|
to raw global GitLab fields cannot be re-scoped as a project save after the
|
|
search changes activeSection.
|
|
*/
|
|
requestSectionChangeRef.current?.(sectionId as SectionId);
|
|
setHighlightedSettingKey(key);
|
|
}, []);
|
|
|
|
/*
|
|
FNXC:SettingsNavigation 2026-07-16-01:10:
|
|
Switching section starts you at the top of it.
|
|
Sections keep no scroll of their own, so the container's offset carried over: leaving a long section scrolled halfway (Scheduling, General) and picking a short one landed mid-content — on mobile, often past everything, on an apparently blank screen with no hint to scroll up.
|
|
Guarded by a ref rather than by reading `highlightedSettingKey`: a search jump also changes `activeSection`, and that key clears itself ~1.6s later, which would re-run this effect and yank the operator away from the row they had just jumped to. The ref is consumed once, so only the jump's own section change is exempt.
|
|
*/
|
|
useEffect(() => {
|
|
if (settingsJumpPendingRef.current) {
|
|
settingsJumpPendingRef.current = false;
|
|
return;
|
|
}
|
|
/*
|
|
FNXC:SettingsNavigation 2026-07-16-01:10:
|
|
Assigns `scrollTop` rather than calling `scrollTo({top:0})`: jsdom implements the property but not the method, so the smarter-looking call throws "scrollTo is not a function" in component tests. The jump is instant either way — a section change is a context switch, not a movement the eye should follow.
|
|
*/
|
|
if (settingsContentRef.current) settingsContentRef.current.scrollTop = 0;
|
|
}, [activeSection]);
|
|
|
|
useEffect(() => {
|
|
if (!highlightedSettingKey) return;
|
|
const container = settingsContentRef.current;
|
|
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;
|
|
|
|
useEffect(() => {
|
|
if ((activeSection === "research-global" || activeSection === "research-project") && !researchViewEnabled) {
|
|
setActiveSection(firstVisibleSectionId);
|
|
return;
|
|
}
|
|
|
|
if (activeSection === "scheduled-evals" && !evalsViewEnabled) {
|
|
setActiveSection(firstVisibleSectionId);
|
|
return;
|
|
}
|
|
|
|
if (!visibleSections.some((section) => section.id === activeSection)) {
|
|
if (RUNTIME_SETTINGS_SECTION_IDS.has(activeSection) && !runtimeVisibilitySettled) return;
|
|
setActiveSection(firstVisibleSectionId);
|
|
return;
|
|
}
|
|
|
|
if (hasSettingsSearchQuery && hasSettingsSearchResults && !searchMatchedSections.some((section) => section.id === activeSection)) {
|
|
setActiveSection(firstSearchMatchedSectionId);
|
|
}
|
|
}, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, firstSearchMatchedSectionId, hasSettingsSearchQuery, hasSettingsSearchResults, runtimeVisibilitySettled, searchMatchedSections, visibleSections]);
|
|
|
|
// Auth state (independent of the settings save flow)
|
|
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
|
const [authLoading, setAuthLoading] = useState(false);
|
|
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
|
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
|
|
const [manualCodeConfigs, setManualCodeConfigs] = useState<Record<string, ManualOAuthCodeInfo>>({});
|
|
const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({});
|
|
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
|
|
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
|
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
|
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
|
const [opencodeApiKeyRefreshStatus, setOpencodeApiKeyRefreshStatus] = useState<Record<string, {
|
|
tone: "success" | "error";
|
|
message: string;
|
|
}>>({});
|
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const lastAutoCopiedDeviceCodesRef = useRef<Record<string, string>>({});
|
|
|
|
// Model state
|
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
|
const [modelsLoading, setModelsLoading] = useState(false);
|
|
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
|
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
|
|
|
// Test notification state
|
|
const [testNotificationLoading, setTestNotificationLoading] = useState<Record<string, boolean>>({});
|
|
const [testNotificationResult, setTestNotificationResult] = useState<Record<string, { status: "success" | "error"; message: string }>>({});
|
|
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
|
|
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
|
|
|
|
// Backup state
|
|
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
|
|
const [backupLoading, setBackupLoading] = useState(false);
|
|
|
|
// Remote access state
|
|
const [remoteStatus, setRemoteStatus] = useState<RemoteStatus | null>(null);
|
|
const [externalTunnel, setExternalTunnel] = useState<{ provider: string; url: string | null } | null>(null);
|
|
const [remoteBusyAction, setRemoteBusyAction] = useState<string | null>(null);
|
|
const [cloudflaredInstalling, setCloudflaredInstalling] = useState(false);
|
|
const [cloudflaredInstallError, setCloudflaredInstallError] = useState<string | null>(null);
|
|
const [remoteAuthLinkTokenType, setRemoteAuthLinkTokenType] = useState<"persistent" | "short-lived">("persistent");
|
|
const [remoteUrlPreview, setRemoteUrlPreview] = useState<{ url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null>(null);
|
|
const [remoteQrSvg, setRemoteQrSvg] = useState<string | null>(null);
|
|
const [remoteShortLivedToken, setRemoteShortLivedToken] = useState<{ token: string; expiresAt: string; ttlMs: number } | null>(null);
|
|
const [tunnelShareLink, setTunnelShareLink] = useState<{ url: string; qrSvg: string | null } | null>(null);
|
|
|
|
// Project memory state
|
|
const [memoryContent, setMemoryContent] = useState("");
|
|
const [memoryLoading, setMemoryLoading] = useState(false);
|
|
const [memoryDirty, setMemoryDirty] = useState(false);
|
|
// Git remotes for the worktree rebase dropdown. Loaded lazily; empty list
|
|
// is a valid state (fresh repo, no remotes configured yet).
|
|
const [gitRemotes, setGitRemotes] = useState<GitRemoteDetailed[]>([]);
|
|
// Branch list for the Integration branch dropdown. Loaded lazily when the
|
|
// merge section becomes visible. Empty list is a valid state (fresh repo /
|
|
// permissions issue); the UI falls back to allowing custom free-text entry.
|
|
const [integrationBranchOptions, setIntegrationBranchOptions] = useState<string[]>([]);
|
|
// Sticky toggle: once the user picks "Custom..." we keep them in text-input
|
|
// mode even when the typed value happens to match an existing branch.
|
|
const [integrationBranchCustomMode, setIntegrationBranchCustomMode] = useState(false);
|
|
const [projectTrackingRepoOptions, setProjectTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
|
|
const [projectTrackingRepoLoading, setProjectTrackingRepoLoading] = useState(false);
|
|
const [projectTrackingRepoError, setProjectTrackingRepoError] = useState<string | null>(null);
|
|
const [globalTrackingRepoOptions, setGlobalTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
|
|
const [globalTrackingRepoLoading, setGlobalTrackingRepoLoading] = useState(false);
|
|
const [globalTrackingRepoError, setGlobalTrackingRepoError] = useState<string | null>(null);
|
|
const globalTrackingRepoLoadedRef = useRef(false);
|
|
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
|
const [selectedMemoryPath, setSelectedMemoryPath] = useState(DEFAULT_MEMORY_EDITOR_PATH);
|
|
const [memoryTestQuery, setMemoryTestQuery] = useState("");
|
|
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
|
|
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
|
|
const [dreamRunning, setDreamRunning] = useState(false);
|
|
const [memoryCompactLoading, setMemoryCompactLoading] = useState(false);
|
|
const [qmdInstallLoading, setQmdInstallLoading] = useState(false);
|
|
const skipNextMemoryReloadRef = useRef(false);
|
|
|
|
// Global concurrency state
|
|
/*
|
|
FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half):
|
|
The machine-wide cap's state, its dedicated fetch, its save branch and its dirty
|
|
tracking are DELETED along with the Scheduling · Global section that hosted it.
|
|
Capacity is two numbers PER PROJECT, both of which live in the settings form.
|
|
*/
|
|
|
|
// Import/Export state
|
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
|
const [, setImportFile] = useState<File | null>(null);
|
|
const [importPreview, setImportPreview] = useState<SettingsExportData | null>(null);
|
|
const [importLoading, setImportLoading] = useState(false);
|
|
const [importScope, setImportScope] = useState<'global' | 'project' | 'both'>('both');
|
|
const [importMerge, setImportMerge] = useState(true);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Memory backend status - called at component top level to comply with React Rules of Hooks
|
|
const {
|
|
status: memoryBackendStatus,
|
|
capabilities: memoryCapabilities,
|
|
loading: memoryBackendLoading,
|
|
error: memoryBackendError,
|
|
refresh: refreshMemoryBackend,
|
|
} = useMemoryBackendStatus({
|
|
projectId,
|
|
enabled: activeSection === "memory",
|
|
});
|
|
|
|
const settingsNavResizeEnabled = !showMobileSectionPicker;
|
|
const settingsNavigationStyle = settingsNavResizeEnabled
|
|
? ({ "--settings-nav-width": `${settingsNavWidth}px` } as CSSProperties)
|
|
: undefined;
|
|
|
|
const endSettingsNavResize = useCallback((pointerId?: number, target?: EventTarget | null) => {
|
|
const dragState = settingsNavDragRef.current;
|
|
if (!dragState) return;
|
|
document.body.style.userSelect = dragState.previousUserSelect;
|
|
settingsNavDragRef.current = null;
|
|
if (typeof pointerId === "number" && target instanceof HTMLElement && typeof target.releasePointerCapture === "function") {
|
|
try {
|
|
target.releasePointerCapture(pointerId);
|
|
} catch {
|
|
// Pointer capture may already be released by the browser; cleanup is still complete.
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const handleSettingsNavResizePointerMove = useCallback((event: PointerEvent) => {
|
|
const dragState = settingsNavDragRef.current;
|
|
if (!dragState) return;
|
|
event.preventDefault();
|
|
persistSettingsNavWidth(dragState.startWidth + event.clientX - dragState.startX);
|
|
}, [persistSettingsNavWidth]);
|
|
|
|
const handleSettingsNavResizePointerUp = useCallback((event: PointerEvent) => {
|
|
endSettingsNavResize(event.pointerId, event.target);
|
|
}, [endSettingsNavResize]);
|
|
|
|
const handleSettingsNavResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (!settingsNavResizeEnabled) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (typeof event.currentTarget.setPointerCapture === "function") {
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
}
|
|
settingsNavDragRef.current = {
|
|
startX: event.clientX,
|
|
startWidth: settingsNavWidth,
|
|
previousUserSelect: document.body.style.userSelect,
|
|
};
|
|
document.body.style.userSelect = "none";
|
|
}, [settingsNavResizeEnabled, settingsNavWidth]);
|
|
|
|
const handleSettingsNavResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
if (!settingsNavResizeEnabled) return;
|
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
event.preventDefault();
|
|
persistSettingsNavWidth(settingsNavWidth + (event.key === "ArrowRight" ? 16 : -16));
|
|
}, [persistSettingsNavWidth, settingsNavResizeEnabled, settingsNavWidth]);
|
|
|
|
useEffect(() => {
|
|
if (!settingsNavResizeEnabled) {
|
|
endSettingsNavResize();
|
|
return;
|
|
}
|
|
document.addEventListener("pointermove", handleSettingsNavResizePointerMove);
|
|
document.addEventListener("pointerup", handleSettingsNavResizePointerUp);
|
|
document.addEventListener("pointercancel", handleSettingsNavResizePointerUp);
|
|
return () => {
|
|
document.removeEventListener("pointermove", handleSettingsNavResizePointerMove);
|
|
document.removeEventListener("pointerup", handleSettingsNavResizePointerUp);
|
|
document.removeEventListener("pointercancel", handleSettingsNavResizePointerUp);
|
|
endSettingsNavResize();
|
|
};
|
|
}, [endSettingsNavResize, handleSettingsNavResizePointerMove, handleSettingsNavResizePointerUp, settingsNavResizeEnabled]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
return;
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY);
|
|
if (!mediaQuery) {
|
|
return;
|
|
}
|
|
const updateMobilePicker = (event?: MediaQueryListEvent) => {
|
|
setShowMobileSectionPicker(viewportMode === "mobile" || (event ? event.matches : mediaQuery.matches));
|
|
};
|
|
|
|
updateMobilePicker();
|
|
mediaQuery.addEventListener("change", updateMobilePicker);
|
|
return () => mediaQuery.removeEventListener("change", updateMobilePicker);
|
|
}, [viewportMode]);
|
|
|
|
/*
|
|
FNXC:SettingsReset 2026-07-04-00:15:
|
|
Factored out of the initial-load effect so the FN-7506 reset handlers can
|
|
re-fetch and re-normalize the merged + scoped settings after a reset write,
|
|
refreshing the form to the just-reset values without duplicating the
|
|
normalization logic. `showLoadingState` is false for post-reset refreshes so
|
|
the whole modal doesn't flash back to the loading spinner.
|
|
*/
|
|
const refreshSettingsForm = useCallback((showLoadingState: boolean) => {
|
|
// Load both merged and scoped settings to enable inheritance detection
|
|
return Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)])
|
|
.then(([s, scoped]) => {
|
|
const normalizedSettings = {
|
|
...s,
|
|
ignoreHiddenOverlapPaths: s.ignoreHiddenOverlapPaths ?? true,
|
|
allowAbsoluteFileBrowserPaths: s.allowAbsoluteFileBrowserPaths === true,
|
|
/*
|
|
FNXC:TaskCardCostBadge 2026-07-11-12:15:
|
|
The Settings form normalizes missing showCostBadgeOnCards to false so upgraded projects retain no card spend badge until an operator explicitly opts in.
|
|
*/
|
|
showCostBadgeOnCards: s.showCostBadgeOnCards === true,
|
|
/*
|
|
FNXC:TaskDetailActivityFirst 2026-06-30-23:59:
|
|
The Settings form normalizes missing taskDetailChatFirst to false so new and upgraded projects show the Activity-first default until an operator explicitly opts into Chat-first.
|
|
*/
|
|
taskDetailChatFirst: s.taskDetailChatFirst === true,
|
|
/*
|
|
FNXC:GithubImportTracking 2026-07-01-00:00:
|
|
Missing githubLinkImportedIssuesToTracking must render as unchecked and save as project-scoped false only after operator interaction; this keeps upgraded projects on legacy import behavior by default.
|
|
*/
|
|
githubLinkImportedIssuesToTracking: s.githubLinkImportedIssuesToTracking === true,
|
|
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
|
|
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
|
|
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
|
|
executorToolFailureRetryCount: resolveNonNegativeExecutorToolFailureSetting(s.executorToolFailureRetryCount, 2),
|
|
executorToolFailureRetryBackoffMs: resolveNonNegativeExecutorToolFailureSetting(s.executorToolFailureRetryBackoffMs, 2000),
|
|
executorToolFailureThreshold: Math.max(1, Math.floor(Number(s.executorToolFailureThreshold ?? 3) || 3)),
|
|
executorModelEscalationEnabled: s.executorModelEscalationEnabled === true,
|
|
executorEscalationProvider: s.executorEscalationProvider ?? "",
|
|
executorEscalationModelId: s.executorEscalationModelId ?? "",
|
|
executorEscalationNodeId: s.executorEscalationNodeId ?? "",
|
|
worktreeCopyFiles: Array.isArray(s.worktreeCopyFiles) ? s.worktreeCopyFiles : [],
|
|
};
|
|
setForm(normalizedSettings);
|
|
setInitialValues(normalizedSettings); // Store initial values to detect explicit clears
|
|
setScopedSettings(scoped);
|
|
setGlobalGitlabSettings({
|
|
gitlabEnabled: scoped.global.gitlabEnabled,
|
|
gitlabInstanceUrl: scoped.global.gitlabInstanceUrl,
|
|
gitlabApiBaseUrl: scoped.global.gitlabApiBaseUrl,
|
|
gitlabAuthToken: scoped.global.gitlabAuthToken,
|
|
gitlabAuthTokenType: scoped.global.gitlabAuthTokenType,
|
|
reportRoadmapDedupeEnabled: scoped.global.reportRoadmapDedupeEnabled,
|
|
reportRoadmapLabel: scoped.global.reportRoadmapLabel,
|
|
reportRoadmapRepo: scoped.global.reportRoadmapRepo,
|
|
});
|
|
setInitialScopedValues({
|
|
...scoped,
|
|
project: {
|
|
...scoped.project,
|
|
mergeIntegrationWorktree: scoped.project.mergeIntegrationWorktree === undefined
|
|
? undefined
|
|
: normalizeMergeIntegrationWorktreeMode(scoped.project.mergeIntegrationWorktree),
|
|
mergeAdvanceAutoSync: scoped.project.mergeAdvanceAutoSync === undefined
|
|
? undefined
|
|
: normalizeMergeAdvanceAutoSyncMode(scoped.project.mergeAdvanceAutoSync),
|
|
},
|
|
}); // Store initial scoped values for null-as-delete
|
|
if (showLoadingState) {
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
addToast(getErrorMessage(err), "error");
|
|
if (showLoadingState) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
}, [addToast, projectId]);
|
|
|
|
useEffect(() => {
|
|
void refreshSettingsForm(true);
|
|
}, [addToast, projectId]);
|
|
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
fetchDashboardHealth()
|
|
.then((health) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
if (typeof health.version === "string" && health.version.trim().length > 0) {
|
|
setAppVersion(health.version);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Non-blocking metadata only — settings remains usable when unavailable.
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const handleCheckForUpdates = useCallback(async () => {
|
|
setUpdateCheckLoading(true);
|
|
setUpdateInstallResult(null);
|
|
/*
|
|
FNXC:SettingsUpdate 2026-07-25-10:05:
|
|
Do NOT clear restartSupported here. It is a property of the HOST (is there a
|
|
supervising parent?), not of this update check, and clearing it stranded the
|
|
post-update "Restart Fusion" button: the capability effect was keyed on
|
|
updateAvailable, so a second "Check now" that returned the same
|
|
updateAvailable=true left restartSupported permanently `undefined` and the
|
|
button disabled with "Needs a supervising parent" on a perfectly supervised
|
|
host. The probe below owns this state for the modal's lifetime.
|
|
*/
|
|
setRestartLoading(false);
|
|
setRestartScheduled(false);
|
|
setRestartError(null);
|
|
|
|
try {
|
|
const result = await checkForUpdates();
|
|
setUpdateCheckResult(result);
|
|
|
|
if (result.error) {
|
|
addToast(result.error, "error");
|
|
}
|
|
} catch (error) {
|
|
const message = getErrorMessage(error) || t("settings.general.updateCheckFailed", "Failed to check for updates");
|
|
setUpdateCheckResult({
|
|
currentVersion: appVersion ?? "unknown",
|
|
latestVersion: null,
|
|
updateAvailable: false,
|
|
error: message,
|
|
});
|
|
addToast(message, "error");
|
|
} finally {
|
|
setUpdateCheckLoading(false);
|
|
}
|
|
}, [addToast, appVersion, t]);
|
|
|
|
const handleInstallUpdate = useCallback(async () => {
|
|
setUpdateInstallLoading(true);
|
|
setUpdateInstallResult(null);
|
|
setRestartLoading(false);
|
|
setRestartScheduled(false);
|
|
setRestartError(null);
|
|
|
|
try {
|
|
const result = await installUpdate(projectId);
|
|
setUpdateInstallResult(result);
|
|
|
|
if (result.error) {
|
|
addToast(result.error, "error");
|
|
return;
|
|
}
|
|
|
|
if (result.updated) {
|
|
addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success");
|
|
/*
|
|
FNXC:SettingsUpdate 2026-07-25-10:05:
|
|
Re-probe capability right before the restart button appears so a transient
|
|
/system/info failure at mount (which fails closed to `false`) cannot leave a
|
|
supervised host permanently unable to restart from Settings.
|
|
*/
|
|
void fetchSystemInfo()
|
|
.then((info) => setRestartSupported(info.restartSupported))
|
|
.catch(() => {
|
|
// Keep whatever the mount probe resolved; the guidance text covers it.
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed");
|
|
setUpdateInstallResult({
|
|
currentVersion: updateCheckResult?.currentVersion ?? appVersion ?? "unknown",
|
|
latestVersion: updateCheckResult?.latestVersion ?? null,
|
|
updated: false,
|
|
error: message,
|
|
});
|
|
addToast(message, "error");
|
|
} finally {
|
|
setUpdateInstallLoading(false);
|
|
}
|
|
}, [addToast, appVersion, projectId, t, updateCheckResult]);
|
|
|
|
/*
|
|
FNXC:SettingsUpdate 2026-07-25-10:05:
|
|
Probe host restart capability once when Settings mounts — same unconditional
|
|
shape UpdateAvailableBanner and the Command Center System panel use. It used to
|
|
run only after an update became available, which made the state order-dependent
|
|
and left the restart button dead in the re-check case described above. Fetching
|
|
on mount means the capability is already resolved by the time an install
|
|
finishes, so the button is enabled the moment it appears.
|
|
*/
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void fetchSystemInfo()
|
|
.then((info) => {
|
|
if (!cancelled) setRestartSupported(info.restartSupported);
|
|
})
|
|
.catch(() => {
|
|
// Fail closed: system capability fetch errors must not expose an unavailable restart action.
|
|
if (!cancelled) setRestartSupported(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
/*
|
|
FNXC:SettingsUpdate 2026-07-16-00:00:
|
|
After a successful in-app update, the Settings footer must offer the same supervised
|
|
one-click restart as SystemControlsArea.
|
|
|
|
FNXC:SettingsUpdate 2026-07-25-10:05:
|
|
The control must never silently do nothing. It used to be hard-disabled on
|
|
`restartSupported !== true`, so any host whose capability probe answered false —
|
|
including a probe that merely failed, or a stale answer — left the operator with a
|
|
dead button and no way to learn why ("the restart button does nothing"). Now the
|
|
click always reaches the server and the server's own refusal is shown inline; the
|
|
supervising-parent line stays as advisory guidance rather than a hard block.
|
|
*/
|
|
const handleRestart = useCallback(async () => {
|
|
if (restartLoading) return;
|
|
|
|
setRestartLoading(true);
|
|
setRestartError(null);
|
|
try {
|
|
const result = await requestSystemRestart("settings-update");
|
|
if (result.scheduled) {
|
|
setRestartScheduled(true);
|
|
} else {
|
|
setRestartError(t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually."));
|
|
}
|
|
} catch (error) {
|
|
setRestartError(getErrorMessage(error) || t("settings.general.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually."));
|
|
} finally {
|
|
setRestartLoading(false);
|
|
}
|
|
}, [restartLoading, t]);
|
|
|
|
const renderUpdateCheckResultContent = useCallback(() => {
|
|
if (!updateCheckResult) {
|
|
return null;
|
|
}
|
|
|
|
if (updateCheckResult.error) {
|
|
return updateCheckResult.error;
|
|
}
|
|
|
|
if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) {
|
|
const installSucceeded = updateInstallResult?.updated === true;
|
|
const installError = updateInstallResult?.error;
|
|
|
|
return (
|
|
<>
|
|
<span>
|
|
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "}
|
|
<a
|
|
href="https://runfusion.ai"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="settings-update-result-link"
|
|
>
|
|
{t("settings.general.learnMore", "Learn more")}
|
|
</a>
|
|
</span>
|
|
{installSucceeded ? (
|
|
<span className="settings-update-install-succeeded">
|
|
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
|
|
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
|
|
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
|
|
})}
|
|
</span>
|
|
{restartScheduled ? (
|
|
<span className="settings-update-install-status" aria-live="polite">
|
|
{t("settings.general.restarting", "Restarting… Your connection will close shortly.")}
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm settings-update-now-btn"
|
|
onClick={() => {
|
|
void handleRestart();
|
|
}}
|
|
disabled={restartLoading}
|
|
>
|
|
{restartLoading ? (
|
|
<>
|
|
<RefreshCw size={12} className="spinning" aria-hidden="true" />
|
|
{t("settings.general.restarting", "Restarting…")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Power size={12} aria-hidden="true" />
|
|
{t("settings.general.restartNow", "Restart Fusion")}
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
{/*
|
|
FNXC:SettingsUpdate 2026-07-25-10:05:
|
|
Advisory, not a block. The probe says this host reported no supervising
|
|
parent, so restarting will likely be refused — but the operator can still
|
|
press the button and read the server's actual reason instead of facing a
|
|
dead control.
|
|
*/}
|
|
{restartSupported === false && (
|
|
<span className="settings-update-install-status" aria-live="polite">
|
|
{t("settings.general.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")}
|
|
</span>
|
|
)}
|
|
{restartError && (
|
|
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
|
|
{restartError}
|
|
</span>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm settings-update-now-btn"
|
|
onClick={() => {
|
|
void handleInstallUpdate();
|
|
}}
|
|
disabled={updateInstallLoading}
|
|
>
|
|
{updateInstallLoading ? (
|
|
<>
|
|
<RefreshCw size={12} className="spinning" aria-hidden="true" />
|
|
{t("settings.general.updating", "Updating…")}
|
|
</>
|
|
) : (
|
|
t("settings.general.updateNow", "Update now")
|
|
)}
|
|
</button>
|
|
)}
|
|
{installError && (
|
|
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
|
|
{t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return t("settings.general.upToDate", "You're up to date ✓");
|
|
}, [handleInstallUpdate, handleRestart, restartError, restartLoading, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
|
|
|
|
/*
|
|
FNXC:SettingsUpdate 2026-07-25-19:40:
|
|
The update-check result ("vX available · Learn more" plus the Update now / Restart controls) is one node rendered
|
|
in two places: inline next to the version button on desktop/tablet, and on its OWN full-width row above the footer
|
|
rail on mobile. The mobile footer is a single nowrap horizontally-scrolling rail (FN-7752); once an update banner
|
|
joined that rail its intrinsic width exceeded the viewport, so the banner itself was clipped mid-sentence and
|
|
Import/Export/Reset/Close were pushed off-screen behind a scroll affordance operators do not see. Giving the banner
|
|
its own row keeps the rail to the controls it was sized for, and the banner wraps normally instead of clipping.
|
|
*/
|
|
const updateCheckResultNode = updateCheckResult ? (
|
|
<span
|
|
aria-live="polite"
|
|
className={`settings-update-result ${
|
|
updateCheckResult.error
|
|
? "settings-update-result--error"
|
|
: updateCheckResult.updateAvailable
|
|
? "settings-update-result--available"
|
|
: "settings-update-result--up-to-date"
|
|
}`}
|
|
>
|
|
{renderUpdateCheckResultContent()}
|
|
</span>
|
|
) : null;
|
|
|
|
// Load auth status when the authentication section is active
|
|
const loadAuthStatus = useCallback(async () => {
|
|
try {
|
|
const { providers } = await fetchAuthStatus();
|
|
const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
|
|
setAuthProviders(visibleProviders);
|
|
setLoginInstructions((prev) => {
|
|
const next: Record<string, string> = {};
|
|
for (const [providerId, instructions] of Object.entries(prev)) {
|
|
const provider = visibleProviders.find((candidate) => candidate.id === providerId);
|
|
if (provider && !provider.authenticated) {
|
|
next[providerId] = instructions;
|
|
}
|
|
}
|
|
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
|
|
});
|
|
} catch {
|
|
// Silently fail — auth may not be configured
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (activeSection === "global-models" || activeSection === "project-models") {
|
|
setModelsLoading(true);
|
|
fetchModels()
|
|
.then((response) => {
|
|
setAvailableModels(response.models);
|
|
setFavoriteProviders(response.favoriteProviders);
|
|
setFavoriteModels(response.favoriteModels);
|
|
})
|
|
.catch(() => setAvailableModels([]))
|
|
.finally(() => setModelsLoading(false));
|
|
}
|
|
}, [activeSection]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection === "backups") {
|
|
setBackupLoading(true);
|
|
fetchBackups(projectId)
|
|
.then((info) => setBackupInfo(info))
|
|
.catch(() => setBackupInfo(null))
|
|
.finally(() => setBackupLoading(false));
|
|
}
|
|
}, [activeSection, projectId]);
|
|
|
|
const loadRemoteData = useCallback(async () => {
|
|
const [settingsResult, statusResult] = await Promise.allSettled([
|
|
fetchRemoteSettings(projectId),
|
|
fetchRemoteStatus(projectId),
|
|
]);
|
|
|
|
if (settingsResult.status === "fulfilled") {
|
|
setForm((prev) => ({ ...prev, ...(settingsResult.value.settings as unknown as Partial<SettingsFormState>) }));
|
|
}
|
|
|
|
if (statusResult.status === "fulfilled") {
|
|
setRemoteStatus(statusResult.value);
|
|
setExternalTunnel(statusResult.value.externalTunnel ?? null);
|
|
}
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
const state = remoteStatus?.state;
|
|
if (state === "running" || state === "starting") {
|
|
setExternalTunnel(null);
|
|
}
|
|
}, [remoteStatus?.state]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection !== "remote") {
|
|
return;
|
|
}
|
|
|
|
loadRemoteData().catch(() => {
|
|
setRemoteStatus(null);
|
|
});
|
|
}, [activeSection, loadRemoteData]);
|
|
|
|
// Poll remote status while the tunnel is starting so the UI flips to
|
|
// "running" without the user closing/reopening the modal. Stops polling
|
|
// once it reaches a terminal state.
|
|
useEffect(() => {
|
|
if (activeSection !== "remote") return;
|
|
const state = remoteStatus?.state;
|
|
if (state !== "starting" && state !== "stopping") return;
|
|
const interval = setInterval(() => {
|
|
fetchRemoteStatus(projectId)
|
|
.then((status) => {
|
|
setRemoteStatus(status);
|
|
setExternalTunnel(status.externalTunnel ?? null);
|
|
})
|
|
.catch(() => {});
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [activeSection, projectId, remoteStatus?.state]);
|
|
|
|
// When the tunnel is running, fetch a persistent-token authenticated URL +
|
|
// QR so the user can share/scan it without digging into Advanced Settings.
|
|
useEffect(() => {
|
|
if (activeSection !== "remote") return;
|
|
if (remoteStatus?.state !== "running") {
|
|
setTunnelShareLink(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
|
|
if (cancelled) return;
|
|
setTunnelShareLink({ url: qr.url, qrSvg: qr.data ?? null });
|
|
} catch {
|
|
if (cancelled) return;
|
|
try {
|
|
const link = await fetchRemoteUrl({ projectId, tokenType: "persistent" });
|
|
if (cancelled) return;
|
|
setTunnelShareLink({ url: link.url, qrSvg: null });
|
|
} catch {
|
|
if (!cancelled) setTunnelShareLink(null);
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeSection, projectId, remoteStatus?.state, remoteStatus?.url]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection !== "remote") return;
|
|
const tunnelUrl = externalTunnel?.url;
|
|
if (remoteStatus?.state !== "stopped" || !tunnelUrl) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: "persistent" });
|
|
if (cancelled) return;
|
|
setTunnelShareLink({ url: tunnelUrl, qrSvg: qr.data ?? null });
|
|
} catch {
|
|
if (!cancelled) {
|
|
setTunnelShareLink({ url: tunnelUrl, qrSvg: null });
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeSection, externalTunnel?.url, projectId, remoteStatus?.state]);
|
|
|
|
// Lazy-load git remotes for the rebase-remote dropdown when the Worktrees
|
|
// section becomes visible. Failure is non-fatal: the dropdown falls back
|
|
// to just "Use git default".
|
|
useEffect(() => {
|
|
if (activeSection !== "worktrees") return;
|
|
fetchGitRemotesDetailed(projectId)
|
|
.then((remotes) => setGitRemotes(remotes))
|
|
.catch(() => setGitRemotes([]));
|
|
}, [activeSection, projectId]);
|
|
|
|
// Load local branch list when the merge section becomes visible, so the
|
|
// Integration branch dropdown shows actual options instead of forcing
|
|
// free-text entry. Best-effort — falls back to empty list (custom-only).
|
|
useEffect(() => {
|
|
if (activeSection !== "merge") return;
|
|
/*
|
|
FNXC:MergePush 2026-07-11-22:50:
|
|
The push-after-merge target is now picked from dropdowns (remote + branch on that
|
|
remote) instead of a free-text field, so the merge section also needs the remote
|
|
list. Best-effort — an empty list makes MergeSection fall back to free-text entry.
|
|
*/
|
|
fetchGitRemotesDetailed(projectId)
|
|
.then((remotes) => setGitRemotes(remotes))
|
|
.catch(() => setGitRemotes([]));
|
|
fetchGitBranches(projectId)
|
|
.then((branches) => {
|
|
const names = branches
|
|
.map((b) => b.name)
|
|
.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
// Dedup + sort, with common integration names first.
|
|
const seen = new Set<string>();
|
|
const priority = ["main", "master", "trunk", "develop"];
|
|
const ordered: string[] = [];
|
|
for (const name of priority) {
|
|
if (names.includes(name) && !seen.has(name)) {
|
|
ordered.push(name);
|
|
seen.add(name);
|
|
}
|
|
}
|
|
for (const name of [...names].sort((a, b) => a.localeCompare(b))) {
|
|
if (seen.has(name)) continue;
|
|
seen.add(name);
|
|
ordered.push(name);
|
|
}
|
|
setIntegrationBranchOptions(ordered);
|
|
})
|
|
.catch(() => setIntegrationBranchOptions([]));
|
|
}, [activeSection, projectId]);
|
|
|
|
useEffect(() => {
|
|
// 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;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setProjectTrackingRepoLoading(true);
|
|
setProjectTrackingRepoError(null);
|
|
|
|
fetchGitRemotes(projectId)
|
|
.then((remotes) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setProjectTrackingRepoOptions(toTrackingRepoOptions(remotes));
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setProjectTrackingRepoOptions([]);
|
|
setProjectTrackingRepoError("Could not load detected remotes. Enter a custom owner/repo value.");
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setProjectTrackingRepoLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeSection, projectId]);
|
|
|
|
useEffect(() => {
|
|
// 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;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setGlobalTrackingRepoLoading(true);
|
|
setGlobalTrackingRepoError(null);
|
|
|
|
fetchProjects()
|
|
.then(async (projects) => {
|
|
const results = await Promise.allSettled(
|
|
projects.map(async (project: ProjectInfo) => {
|
|
const remotes = await fetchGitRemotes(project.id);
|
|
return remotes.map((remote) => ({
|
|
value: `${remote.owner}/${remote.repo}`,
|
|
label: `${remote.owner}/${remote.repo}`,
|
|
source: project.name,
|
|
}));
|
|
}),
|
|
);
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const optionsByValue = new Map<string, TrackingRepoOption>();
|
|
let successCount = 0;
|
|
|
|
for (const result of results) {
|
|
if (result.status !== "fulfilled") {
|
|
continue;
|
|
}
|
|
successCount += 1;
|
|
for (const option of result.value) {
|
|
if (!optionsByValue.has(option.value)) {
|
|
optionsByValue.set(option.value, option);
|
|
}
|
|
}
|
|
}
|
|
|
|
const flattenedOptions = [...optionsByValue.values()].sort((a, b) => a.value.localeCompare(b.value));
|
|
setGlobalTrackingRepoOptions(flattenedOptions);
|
|
|
|
if (projects.length > 0 && successCount === 0) {
|
|
setGlobalTrackingRepoError("Could not load remotes from registered projects. Enter a custom owner/repo value.");
|
|
}
|
|
|
|
globalTrackingRepoLoadedRef.current = true;
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setGlobalTrackingRepoOptions([]);
|
|
setGlobalTrackingRepoError("Could not load project list. Enter a custom owner/repo value.");
|
|
globalTrackingRepoLoadedRef.current = true;
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setGlobalTrackingRepoLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeSection, projectId]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection !== "memory" || memoryDirty) {
|
|
return;
|
|
}
|
|
if (skipNextMemoryReloadRef.current) {
|
|
skipNextMemoryReloadRef.current = false;
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setMemoryLoading(true);
|
|
fetchMemoryFiles(projectId)
|
|
.then(async ({ files }) => {
|
|
if (cancelled) return;
|
|
setMemoryFiles(files);
|
|
const nextPath = files.some((file) => file.path === selectedMemoryPath)
|
|
? selectedMemoryPath
|
|
: files.find((file) => file.path === DEFAULT_MEMORY_EDITOR_PATH)?.path
|
|
?? files.find((file) => file.layer === "dreams")?.path
|
|
?? files[0]?.path
|
|
?? DEFAULT_MEMORY_EDITOR_PATH;
|
|
setSelectedMemoryPath(nextPath);
|
|
const { content } = await fetchMemoryFile(nextPath, projectId);
|
|
if (cancelled) return;
|
|
setMemoryContent(content);
|
|
setMemoryDirty(false);
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
addToast(getErrorMessage(err) || "Failed to load project memory", "error");
|
|
setMemoryContent("");
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setMemoryLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeSection, memoryDirty, selectedMemoryPath, projectId, addToast]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection === "authentication" || activeSection === "research-global") {
|
|
setAuthLoading(true);
|
|
loadAuthStatus().finally(() => setAuthLoading(false));
|
|
}
|
|
// Clean up polling when leaving auth section
|
|
return () => {
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
};
|
|
}, [activeSection, loadAuthStatus]);
|
|
|
|
useEffect(() => {
|
|
if (activeSection !== "authentication") {
|
|
return;
|
|
}
|
|
|
|
const hasPendingServerLogin = authProviders.some((provider) => provider.type !== "api_key" && provider.loginInProgress);
|
|
if (!hasPendingServerLogin) {
|
|
return;
|
|
}
|
|
|
|
const interval = setInterval(() => {
|
|
void loadAuthStatus();
|
|
}, 2000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [activeSection, authProviders, loadAuthStatus]);
|
|
|
|
/*
|
|
FNXC:ProviderAuth 2026-07-05-00:00:
|
|
Interactive OAuth login finishes in the background after the auth URL/paste box is shown, so a failure (bad/expired pasted code, token-exchange rejection) only appears in the polled `/auth/status` as `loginError`. Surface that cause to the user once per distinct error instead of leaving login silently stuck, so paste-callback failures are diagnosable from the UI rather than only "it fails".
|
|
*/
|
|
const shownLoginErrorsRef = useRef<Record<string, string>>({});
|
|
useEffect(() => {
|
|
for (const provider of authProviders) {
|
|
const error = provider.loginError;
|
|
if (!error) {
|
|
if (provider.id in shownLoginErrorsRef.current) {
|
|
delete shownLoginErrorsRef.current[provider.id];
|
|
}
|
|
continue;
|
|
}
|
|
if (shownLoginErrorsRef.current[provider.id] === error) {
|
|
continue;
|
|
}
|
|
shownLoginErrorsRef.current[provider.id] = error;
|
|
addToast(`${provider.name} login failed: ${error}`, "error");
|
|
}
|
|
}, [authProviders, addToast]);
|
|
|
|
const scrollSettingsToTop = useCallback(() => {
|
|
settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
}, []);
|
|
|
|
const clearAuthLoginUiState = useCallback((providerId: string) => {
|
|
if (providerId in lastAutoCopiedDeviceCodesRef.current) {
|
|
const next = { ...lastAutoCopiedDeviceCodesRef.current };
|
|
delete next[providerId];
|
|
lastAutoCopiedDeviceCodesRef.current = next;
|
|
}
|
|
setLoginInstructions((prev) => {
|
|
if (!(providerId in prev)) {
|
|
return prev;
|
|
}
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
setManualCodeConfigs((prev) => {
|
|
if (!(providerId in prev)) {
|
|
return prev;
|
|
}
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
setManualCodeInputs((prev) => {
|
|
if (!(providerId in prev)) {
|
|
return prev;
|
|
}
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
setDeviceCodes((prev) => {
|
|
if (!(providerId in prev)) {
|
|
return prev;
|
|
}
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const copilotDeviceCode = deviceCodes["github-copilot"];
|
|
if (!copilotDeviceCode?.userCode) {
|
|
return;
|
|
}
|
|
|
|
if (lastAutoCopiedDeviceCodesRef.current["github-copilot"] === copilotDeviceCode.userCode) {
|
|
return;
|
|
}
|
|
|
|
lastAutoCopiedDeviceCodesRef.current["github-copilot"] = copilotDeviceCode.userCode;
|
|
void copyTextToClipboard(copilotDeviceCode.userCode);
|
|
}, [deviceCodes]);
|
|
|
|
const handleLogin = useCallback(async (providerId: string) => {
|
|
const provider = authProviders.find((entry) => entry.id === providerId);
|
|
if (provider?.requiresManualCode === true) {
|
|
const shouldContinue = await confirm({
|
|
title: t("settings.auth.manualPasteTitle", "Heads up — manual paste-back required"),
|
|
message: t("settings.auth.manualPasteMessage", "After you sign in with {{name}}, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?", { name: provider.name }),
|
|
confirmLabel: t("settings.auth.continueToLogin", "Continue to login"),
|
|
cancelLabel: t("settings.actions.cancel", "Cancel"),
|
|
});
|
|
if (!shouldContinue) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
setAuthActionInProgress(providerId);
|
|
clearAuthLoginUiState(providerId);
|
|
|
|
try {
|
|
const { url, instructions, manualCode, deviceCode } = await loginProvider(providerId);
|
|
if (instructions?.trim() && !(providerId === "github-copilot" && deviceCode)) {
|
|
setLoginInstructions((prev) => ({ ...prev, [providerId]: instructions }));
|
|
}
|
|
if (manualCode) {
|
|
setManualCodeConfigs((prev) => ({ ...prev, [providerId]: manualCode }));
|
|
}
|
|
if (deviceCode && providerId === "github-copilot") {
|
|
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
|
|
}
|
|
if (providerId !== "github-copilot" || !deviceCode) {
|
|
openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url));
|
|
}
|
|
|
|
// Poll for auth completion every 2 seconds
|
|
pollIntervalRef.current = setInterval(async () => {
|
|
try {
|
|
const { providers } = await fetchAuthStatus();
|
|
const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
|
|
setAuthProviders(visibleProviders);
|
|
const provider = visibleProviders.find((p) => p.id === providerId);
|
|
if (provider?.authenticated) {
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
setAuthActionInProgress(null);
|
|
clearAuthLoginUiState(providerId);
|
|
addToast(t("settings.auth.loginSuccessful", "Login successful"), "success");
|
|
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } }));
|
|
scrollSettingsToTop();
|
|
return;
|
|
}
|
|
|
|
if (!provider?.loginInProgress) {
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
setAuthActionInProgress(null);
|
|
clearAuthLoginUiState(providerId);
|
|
addToast(t("settings.auth.loginDidNotComplete", "Login did not complete. Please try again."), "error");
|
|
}
|
|
} catch {
|
|
// Continue polling on transient errors
|
|
}
|
|
}, 2000);
|
|
} catch (err) {
|
|
const message = getErrorMessage(err) || "Login failed";
|
|
const isConflict = message.includes("already in progress") || (typeof err === "object" && err !== null && "status" in err && (err as { status?: number }).status === 409);
|
|
if (isConflict) {
|
|
addToast(t("settings.auth.loginAlreadyInProgress", "Login already in progress. You can cancel it and retry."), "warning");
|
|
await loadAuthStatus();
|
|
} else {
|
|
addToast(message, "error");
|
|
}
|
|
setAuthActionInProgress(null);
|
|
clearAuthLoginUiState(providerId);
|
|
}
|
|
}, [addToast, authProviders, clearAuthLoginUiState, confirm, loadAuthStatus, scrollSettingsToTop]);
|
|
|
|
const handleSubmitManualCode = useCallback(async (providerId: string) => {
|
|
const code = manualCodeInputs[providerId]?.trim();
|
|
if (!code) {
|
|
addToast(t("settings.auth.pasteRedirectUrlFirst", "Paste the full redirect URL or authorization code first."), "warning");
|
|
return;
|
|
}
|
|
|
|
setManualCodeSubmitInProgress(providerId);
|
|
try {
|
|
const result = await submitProviderManualCode(providerId, code);
|
|
if (result.submitted) {
|
|
setManualCodeInputs((prev) => {
|
|
if (!(providerId in prev)) {
|
|
return prev;
|
|
}
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
addToast(t("settings.auth.authCodeReceived", "Authorization code received. Finishing login…"), "success");
|
|
} else {
|
|
addToast(t("settings.auth.authCodeAlreadySubmitted", "That authorization code was already submitted. Waiting for login…"), "warning");
|
|
}
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to submit authorization code", "error");
|
|
} finally {
|
|
setManualCodeSubmitInProgress(null);
|
|
}
|
|
}, [addToast, manualCodeInputs]);
|
|
|
|
const handleCancelLogin = useCallback(async (providerId: string) => {
|
|
setAuthActionInProgress(providerId);
|
|
setAuthProviders((prev) => prev.map((provider) =>
|
|
provider.id === providerId ? { ...provider, loginInProgress: false } : provider,
|
|
));
|
|
try {
|
|
await cancelProviderLogin(providerId);
|
|
clearAuthLoginUiState(providerId);
|
|
await loadAuthStatus().catch(() => {});
|
|
addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to cancel login", "error");
|
|
} finally {
|
|
setAuthActionInProgress(null);
|
|
setManualCodeSubmitInProgress((prev) => prev === providerId ? null : prev);
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
}
|
|
}, [addToast, clearAuthLoginUiState, loadAuthStatus]);
|
|
|
|
const handleLogout = useCallback(async (providerId: string) => {
|
|
setAuthActionInProgress(providerId);
|
|
try {
|
|
await logoutProvider(providerId);
|
|
await loadAuthStatus();
|
|
addToast(t("settings.auth.loggedOut", "Logged out"), "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Logout failed", "error");
|
|
} finally {
|
|
setAuthActionInProgress(null);
|
|
}
|
|
}, [addToast, loadAuthStatus]);
|
|
|
|
const handleSaveApiKey = useCallback(async (providerId: string) => {
|
|
const key = apiKeyInputs[providerId]?.trim();
|
|
if (!key) {
|
|
setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required" }));
|
|
return;
|
|
}
|
|
setAuthActionInProgress(providerId);
|
|
setApiKeyErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
try {
|
|
const saveResult = await saveApiKey(providerId, key);
|
|
setApiKeyInputs((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
await loadAuthStatus();
|
|
if (providerId === "opencode" || providerId === "opencode-go") {
|
|
const modelsRefreshed = saveResult.modelsRefreshed;
|
|
const refreshReason = saveResult.refreshReason;
|
|
const refreshError = saveResult.refreshError;
|
|
if (refreshError) {
|
|
setOpencodeApiKeyRefreshStatus((prev) => ({
|
|
...prev,
|
|
[providerId]: {
|
|
tone: "error",
|
|
message: `Saved, but model refresh failed: ${refreshError}. Make sure the \`opencode\` CLI is installed on PATH.`,
|
|
},
|
|
}));
|
|
} else if (refreshReason === "no-models-from-cli") {
|
|
setOpencodeApiKeyRefreshStatus((prev) => ({
|
|
...prev,
|
|
[providerId]: {
|
|
tone: "error",
|
|
message: "Saved. The local `opencode` CLI returned no models — run `opencode auth login` and `opencode models opencode --refresh`, then click Save again.",
|
|
},
|
|
}));
|
|
} else if (typeof modelsRefreshed === "number" && modelsRefreshed > 0) {
|
|
setOpencodeApiKeyRefreshStatus((prev) => ({
|
|
...prev,
|
|
[providerId]: {
|
|
tone: "success",
|
|
message: `Refreshed ${modelsRefreshed} opencode-go models.`,
|
|
},
|
|
}));
|
|
} else {
|
|
setOpencodeApiKeyRefreshStatus((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
addToast(t("settings.auth.apiKeySaved", "API key saved"), "success");
|
|
scrollSettingsToTop();
|
|
} catch (err) {
|
|
setApiKeyErrors((prev) => ({ ...prev, [providerId]: getErrorMessage(err) || "Failed to save API key" }));
|
|
if (providerId === "opencode" || providerId === "opencode-go") {
|
|
setOpencodeApiKeyRefreshStatus((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
}
|
|
} finally {
|
|
setAuthActionInProgress(null);
|
|
}
|
|
}, [apiKeyInputs, addToast, loadAuthStatus, scrollSettingsToTop]);
|
|
|
|
const handleClearApiKey = useCallback(async (providerId: string) => {
|
|
setAuthActionInProgress(providerId);
|
|
try {
|
|
await clearApiKey(providerId);
|
|
setApiKeyInputs((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
setApiKeyErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
await loadAuthStatus();
|
|
addToast(t("settings.auth.apiKeyCleared", "API key cleared"), "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to clear API key", "error");
|
|
} finally {
|
|
setAuthActionInProgress(null);
|
|
}
|
|
}, [addToast, loadAuthStatus]);
|
|
|
|
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message" | "ntfy-room") => {
|
|
if (providerId === "ntfy" || providerId === "ntfy-message" || providerId === "ntfy-room") {
|
|
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (providerId === "webhook") {
|
|
if (!form.webhookEnabled || !form.webhookUrl?.trim()) {
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = new URL(form.webhookUrl.trim());
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
return;
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
setTestNotificationLoading((prev) => ({ ...prev, [providerId]: true }));
|
|
setTestNotificationResult((prev) => {
|
|
const next = { ...prev };
|
|
delete next[providerId];
|
|
return next;
|
|
});
|
|
try {
|
|
/*
|
|
FNXC:Notifications 2026-06-23-08:49:
|
|
Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test.
|
|
*/
|
|
const currentNtfyConfig = {
|
|
ntfyEnabled: form.ntfyEnabled,
|
|
ntfyTopic: form.ntfyTopic,
|
|
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
|
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
|
};
|
|
const config = providerId === "ntfy"
|
|
? currentNtfyConfig
|
|
: providerId === "ntfy-message"
|
|
? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" }
|
|
: providerId === "ntfy-room"
|
|
? { ...currentNtfyConfig, messageEventType: "message:room" }
|
|
: {
|
|
webhookUrl: form.webhookUrl,
|
|
webhookFormat: form.webhookFormat || "generic",
|
|
};
|
|
const result = await testNotification(
|
|
providerId === "ntfy-message" || providerId === "ntfy-room" ? "ntfy" : providerId,
|
|
config,
|
|
projectId,
|
|
);
|
|
if (result.success) {
|
|
const successMessage = providerId === "ntfy"
|
|
? "Test notification sent — check your ntfy app!"
|
|
: providerId === "ntfy-message"
|
|
? "Message inbox test sent — check your ntfy inbox for the agent-to-user message."
|
|
: providerId === "ntfy-room"
|
|
? "Room reply test sent — check your ntfy inbox for the room reply."
|
|
: "Test notification sent — check your webhook endpoint!";
|
|
setTestNotificationResult((prev) => ({ ...prev, [providerId]: { status: "success", message: successMessage } }));
|
|
addToast(successMessage, "success");
|
|
} else {
|
|
const failureMessage = providerId === "ntfy-message"
|
|
? "Failed to send message inbox test"
|
|
: providerId === "ntfy-room"
|
|
? "Failed to send room reply test"
|
|
: "Failed to send test notification";
|
|
setTestNotificationResult((prev) => ({ ...prev, [providerId]: { status: "error", message: failureMessage } }));
|
|
addToast(failureMessage, "error");
|
|
}
|
|
} catch (err) {
|
|
const failureMessage = getErrorMessage(err) || "Failed to send test notification";
|
|
setTestNotificationResult((prev) => ({ ...prev, [providerId]: { status: "error", message: failureMessage } }));
|
|
addToast(failureMessage, "error");
|
|
} finally {
|
|
setTestNotificationLoading((prev) => ({ ...prev, [providerId]: false }));
|
|
}
|
|
}, [
|
|
addToast,
|
|
form.ntfyAccessToken,
|
|
form.ntfyBaseUrl,
|
|
form.ntfyEnabled,
|
|
form.ntfyTopic,
|
|
form.webhookEnabled,
|
|
form.webhookFormat,
|
|
form.webhookUrl,
|
|
projectId,
|
|
]);
|
|
|
|
const handleBackupNow = useCallback(async () => {
|
|
setBackupLoading(true);
|
|
try {
|
|
const result = await createBackup(projectId);
|
|
if (result.success) {
|
|
addToast(t("settings.backups.backupCreated", "Backup created successfully"), "success");
|
|
// Refresh backup list
|
|
const info = await fetchBackups(projectId);
|
|
setBackupInfo(info);
|
|
} else {
|
|
addToast(result.error || t("settings.backups.createFailed", "Failed to create backup"), "error");
|
|
}
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || t("settings.backups.createFailed", "Failed to create backup"), "error");
|
|
} finally {
|
|
setBackupLoading(false);
|
|
}
|
|
}, [addToast, projectId]);
|
|
|
|
// Export/Import handlers
|
|
const handleExport = useCallback(async () => {
|
|
try {
|
|
// Default scope based on active section
|
|
const scope = activeSectionScope === "global" ? "global" :
|
|
activeSectionScope === "project" ? "project" : "both";
|
|
const data = await exportSettings(scope, projectId);
|
|
|
|
// Create and download the JSON file
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
const filename = `fusion-settings-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`;
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
const scopeLabel = scope === "global"
|
|
? t("settings.importExport.scopeLabel.global", "global")
|
|
: scope === "project"
|
|
? t("settings.importExport.scopeLabel.project", "project")
|
|
: t("settings.importExport.scopeLabel.all", "all");
|
|
addToast(t("settings.importExport.exported", "Settings exported ({{scope}} scope)", { scope: scopeLabel }), "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || t("settings.importExport.exportFailed", "Failed to export settings"), "error");
|
|
}
|
|
}, [addToast, activeSectionScope, projectId]);
|
|
|
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setImportFile(file);
|
|
setImportLoading(true);
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const data = JSON.parse(text) as SettingsExportData;
|
|
setImportPreview(data);
|
|
setImportDialogOpen(true);
|
|
} catch (err) {
|
|
addToast(t("settings.importExport.invalidJson", "Invalid JSON file: {{error}}", { error: getErrorMessage(err) }), "error");
|
|
setImportFile(null);
|
|
} finally {
|
|
setImportLoading(false);
|
|
}
|
|
}, [addToast]);
|
|
|
|
const handleImport = useCallback(async () => {
|
|
if (!importPreview) return;
|
|
|
|
setImportLoading(true);
|
|
try {
|
|
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge }, projectId);
|
|
if (result.success) {
|
|
const parts: string[] = [];
|
|
if (result.globalCount > 0) parts.push(t("settings.importExport.counts.global", "{{count}} global", { count: result.globalCount }));
|
|
if (result.projectCount > 0) parts.push(t("settings.importExport.counts.project", "{{count}} project", { count: result.projectCount }));
|
|
if (result.workflowSettingsCount > 0) parts.push(t("settings.importExport.counts.workflowSettings", "{{count}} workflow setting value", { count: result.workflowSettingsCount }));
|
|
addToast(t("settings.importExport.imported", "Imported {{counts}} setting(s)", { counts: parts.join(", ") }), "success");
|
|
setImportDialogOpen(false);
|
|
setImportPreview(null);
|
|
setImportFile(null);
|
|
// Refresh settings to show imported values
|
|
const refreshed = await fetchSettings(projectId);
|
|
setForm({
|
|
...refreshed,
|
|
ignoreHiddenOverlapPaths: refreshed.ignoreHiddenOverlapPaths ?? true,
|
|
allowAbsoluteFileBrowserPaths: refreshed.allowAbsoluteFileBrowserPaths === true,
|
|
});
|
|
} else {
|
|
addToast(result.error || t("settings.importExport.importFailed", "Import failed"), "error");
|
|
}
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || t("settings.importExport.importFailedDetailed", "Failed to import settings"), "error");
|
|
} finally {
|
|
setImportLoading(false);
|
|
}
|
|
}, [addToast, importPreview, importScope, importMerge, projectId]);
|
|
|
|
const handleToggleFavorite = useCallback(async (provider: string) => {
|
|
const currentFavorites = favoriteProviders;
|
|
const isFavorite = currentFavorites.includes(provider);
|
|
const newFavorites = isFavorite
|
|
? currentFavorites.filter((p) => p !== provider)
|
|
: [provider, ...currentFavorites];
|
|
|
|
setFavoriteProviders(newFavorites);
|
|
|
|
try {
|
|
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
|
} catch {
|
|
setFavoriteProviders(currentFavorites);
|
|
}
|
|
}, [favoriteProviders, favoriteModels]);
|
|
|
|
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
|
const currentFavorites = favoriteModels;
|
|
const isFavorite = currentFavorites.includes(modelId);
|
|
const newFavorites = isFavorite
|
|
? currentFavorites.filter((m) => m !== modelId)
|
|
: [modelId, ...currentFavorites];
|
|
|
|
setFavoriteModels(newFavorites);
|
|
|
|
try {
|
|
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
|
} catch {
|
|
setFavoriteModels(currentFavorites);
|
|
}
|
|
}, [favoriteModels, favoriteProviders]);
|
|
|
|
|
|
/**
|
|
* Lane status types:
|
|
* - "overridden": Both provider and model keys are explicitly set in project scope
|
|
* - "inherited": Provider/model keys are not set in project scope (fallback to global)
|
|
*/
|
|
type LaneStatus = "overridden" | "inherited";
|
|
|
|
/**
|
|
* Model lane keys that can be overridden at the project level.
|
|
* Each lane has global baseline keys and project override keys.
|
|
*/
|
|
interface ModelLane {
|
|
laneId: string;
|
|
label: string;
|
|
globalProviderKey: keyof GlobalSettings;
|
|
globalModelKey: keyof GlobalSettings;
|
|
globalThinkingKey?: keyof GlobalSettings;
|
|
projectProviderKey: keyof Settings;
|
|
projectModelKey: keyof Settings;
|
|
projectThinkingKey?: keyof Settings;
|
|
helperText: string;
|
|
fallbackOrder: string;
|
|
}
|
|
|
|
/*
|
|
FNXC:Settings-MergerModel 2026-07-13-07:52:
|
|
MODEL_LANES drives Global Models + Project Models pickers. Merger is a sixth dedicated lane (project-scoped like summarization, not workflow-moved) so conflict/merge agents can use a different model from executor/planner/reviewer without a separate settings surface.
|
|
*/
|
|
/** All model lanes with their global and project override keys */
|
|
const MODEL_LANES: ModelLane[] = [
|
|
{
|
|
laneId: "default",
|
|
label: "Default Model",
|
|
globalProviderKey: "defaultProvider",
|
|
globalModelKey: "defaultModelId",
|
|
projectProviderKey: "defaultProviderOverride",
|
|
projectModelKey: "defaultModelIdOverride",
|
|
projectThinkingKey: "defaultThinkingLevelOverride",
|
|
helperText: "Default AI model used for task execution when no per-task override is set.",
|
|
fallbackOrder: "Project override → Global default lane → Automatic resolution",
|
|
},
|
|
{
|
|
laneId: "execution",
|
|
label: "Execution Model",
|
|
globalProviderKey: "executionGlobalProvider",
|
|
globalModelKey: "executionGlobalModelId",
|
|
globalThinkingKey: "executionGlobalThinkingLevel",
|
|
projectProviderKey: "executionProvider",
|
|
projectModelKey: "executionModelId",
|
|
helperText: "AI model used for task implementation (executor agent).",
|
|
fallbackOrder: "Project override → Global execution lane → Global default lane → Automatic resolution",
|
|
},
|
|
{
|
|
laneId: "planning",
|
|
label: "Planning Model",
|
|
globalProviderKey: "planningGlobalProvider",
|
|
globalModelKey: "planningGlobalModelId",
|
|
globalThinkingKey: "planningGlobalThinkingLevel",
|
|
projectProviderKey: "planningProvider",
|
|
projectModelKey: "planningModelId",
|
|
helperText: "AI model used for task planning.",
|
|
fallbackOrder: "Project override → Global planning lane → Global default lane → Automatic resolution",
|
|
},
|
|
{
|
|
laneId: "validator",
|
|
label: "Reviewer Model",
|
|
globalProviderKey: "validatorGlobalProvider",
|
|
globalModelKey: "validatorGlobalModelId",
|
|
globalThinkingKey: "validatorGlobalThinkingLevel",
|
|
projectProviderKey: "validatorProvider",
|
|
projectModelKey: "validatorModelId",
|
|
helperText: "AI model used for code and specification review.",
|
|
fallbackOrder: "Project override → Global reviewer lane → Global default lane → Automatic resolution",
|
|
},
|
|
{
|
|
laneId: "merger",
|
|
label: "Merger Model",
|
|
globalProviderKey: "mergerGlobalProvider",
|
|
globalModelKey: "mergerGlobalModelId",
|
|
globalThinkingKey: "mergerGlobalThinkingLevel",
|
|
projectProviderKey: "mergerProvider",
|
|
projectModelKey: "mergerModelId",
|
|
projectThinkingKey: "mergerThinkingLevel",
|
|
helperText: "AI model used for merge conflict resolution, clean-room merge, stash-conflict recovery, and related merger agent sessions.",
|
|
fallbackOrder: "Project override → Global merger lane → Project default lane → Global default lane → Automatic resolution",
|
|
},
|
|
{
|
|
laneId: "summarization",
|
|
label: "Title and Git Commit Message Summarization Model",
|
|
globalProviderKey: "titleSummarizerGlobalProvider",
|
|
globalModelKey: "titleSummarizerGlobalModelId",
|
|
globalThinkingKey: "titleSummarizerGlobalThinkingLevel",
|
|
projectProviderKey: "titleSummarizerProvider",
|
|
projectModelKey: "titleSummarizerModelId",
|
|
projectThinkingKey: "titleSummarizerThinkingLevel",
|
|
helperText: "AI model used for auto-generating task titles and merge commit summaries.",
|
|
fallbackOrder: "Project override → Global summarization lane → Project planning lane → Project default lane → Global default lane → Automatic resolution",
|
|
},
|
|
/*
|
|
FNXC:GitHubImportTranslate 2026-07-15-09:30:
|
|
Import auto-translation gets its OWN lane instead of riding the summarization lane. Translating an imported GitHub/GitLab issue is one short, readonly, per-issue call with no repo context, so operators must be able to pin a cheap/fast model to it without dragging the summarization lane (which titles tasks and writes merge commit messages) onto that same cheap model. It still falls back THROUGH summarization, so operators who do not care get sensible behavior with zero configuration.
|
|
*/
|
|
{
|
|
laneId: "import-translate",
|
|
label: "Import Auto-Translation Model",
|
|
globalProviderKey: "importTranslateGlobalProvider",
|
|
globalModelKey: "importTranslateGlobalModelId",
|
|
globalThinkingKey: "importTranslateGlobalThinkingLevel",
|
|
projectProviderKey: "importTranslateProvider",
|
|
projectModelKey: "importTranslateModelId",
|
|
projectThinkingKey: "importTranslateThinkingLevel",
|
|
helperText: "AI model used to translate foreign-language GitHub/GitLab issue titles and bodies in the Import Tasks panel.",
|
|
fallbackOrder: "Project override → Global import-translate lane → Summarization lane → Project default → Global default",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Compute the status of a model lane from scoped project data.
|
|
* Returns "overridden" when both project lane keys are explicitly set,
|
|
* "inherited" when they are absent (fallback to global lane).
|
|
*/
|
|
function getLaneStatus(lane: ModelLane): LaneStatus {
|
|
if (!scopedSettings?.project) return "inherited";
|
|
const provider = scopedSettings.project[lane.projectProviderKey as keyof Settings];
|
|
const model = scopedSettings.project[lane.projectModelKey as keyof Settings];
|
|
return provider !== undefined || model !== undefined ? "overridden" : "inherited";
|
|
}
|
|
|
|
/**
|
|
* Compute the display value for a model lane dropdown.
|
|
* Returns the provider/model pair when explicitly set, or empty string for inherited.
|
|
*/
|
|
function getLaneValue(lane: ModelLane): string {
|
|
const provider = form[lane.projectProviderKey as keyof Settings] as string | undefined;
|
|
const model = form[lane.projectModelKey as keyof Settings] as string | undefined;
|
|
if (provider && model) {
|
|
return `${provider}/${model}`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Update a model lane's provider and model values in the form.
|
|
*/
|
|
function updateLaneValue(lane: ModelLane, value: string): void {
|
|
if (!value) {
|
|
// Clearing the dropdown - check if this is an inherited lane
|
|
const status = getLaneStatus(lane);
|
|
if (status === "inherited") {
|
|
// Don't write anything to form for inherited lanes
|
|
return;
|
|
}
|
|
// For overridden lanes, setting to undefined clears the override (null-as-delete)
|
|
setForm((f) => ({
|
|
...f,
|
|
[lane.projectProviderKey]: undefined,
|
|
[lane.projectModelKey]: undefined,
|
|
}));
|
|
} else {
|
|
const slashIdx = value.indexOf("/");
|
|
setForm((f) => ({
|
|
...f,
|
|
[lane.projectProviderKey]: value.slice(0, slashIdx),
|
|
[lane.projectModelKey]: value.slice(slashIdx + 1),
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset a model lane back to inherited state (null-as-delete for project override).
|
|
*/
|
|
function resetLaneValue(lane: ModelLane): void {
|
|
const status = getLaneStatus(lane);
|
|
if (status === "inherited") return; // Nothing to reset
|
|
|
|
// Set to undefined to trigger null-as-delete on save
|
|
setForm((f) => ({
|
|
...f,
|
|
[lane.projectProviderKey]: undefined,
|
|
[lane.projectModelKey]: undefined,
|
|
}));
|
|
}
|
|
|
|
|
|
/**
|
|
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
|
|
* SettingsModal owns lane thinking persistence just like lane model persistence. Empty values clear the override so the engine falls back through task > lane > global default without sections writing settings directly.
|
|
*/
|
|
function getLaneThinkingValue(lane: ModelLane, scope: "project" | "global" = "project"): string {
|
|
const key = scope === "project" ? lane.projectThinkingKey : lane.globalThinkingKey;
|
|
return key ? ((form[key as keyof Settings] as string | undefined) ?? "") : "";
|
|
}
|
|
|
|
function updateLaneThinkingValue(lane: ModelLane, level: string, scope: "project" | "global" = "project"): void {
|
|
const key = scope === "project" ? lane.projectThinkingKey : lane.globalThinkingKey;
|
|
if (!key) return;
|
|
setForm((f) => ({
|
|
...f,
|
|
[key]: level || undefined,
|
|
}));
|
|
}
|
|
|
|
function resetLaneThinkingValue(lane: ModelLane, scope: "project" | "global" = "project"): void {
|
|
updateLaneThinkingValue(lane, "", scope);
|
|
}
|
|
|
|
const openOverlapPathPicker = useCallback((index: number) => {
|
|
setOverlapPathPickerIndex(index);
|
|
setOverlapPathPickerPath(".");
|
|
}, [setOverlapPathPickerPath]);
|
|
|
|
const closeOverlapPathPicker = useCallback(() => {
|
|
setOverlapPathPickerIndex(null);
|
|
}, []);
|
|
|
|
const selectOverlapIgnorePath = useCallback((path: string) => {
|
|
if (overlapPathPickerIndex === null) return;
|
|
|
|
/*
|
|
FNXC:FileBrowserAbsolutePaths 2026-06-29-00:00:
|
|
The project-level absolute file-browser setting must not widen settings fields whose saved values are consumed as project-relative patterns. Reject slash-prefixed picker selections at the form boundary so overlapIgnorePaths cannot persist filesystem-absolute paths.
|
|
*/
|
|
if (isSlashPrefixedAbsolutePath(path)) return;
|
|
|
|
setForm((f) => {
|
|
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
|
? [...f.overlapIgnorePaths]
|
|
: [""];
|
|
currentPaths[overlapPathPickerIndex] = path;
|
|
return { ...f, overlapIgnorePaths: currentPaths };
|
|
});
|
|
|
|
closeOverlapPathPicker();
|
|
}, [overlapPathPickerIndex, closeOverlapPathPicker]);
|
|
|
|
const handleSelectCurrentDirectoryForOverlapIgnore = useCallback(() => {
|
|
if (overlapPathPickerCurrentPath === ".") {
|
|
return;
|
|
}
|
|
|
|
const directoryPath = overlapPathPickerCurrentPath.endsWith("/")
|
|
? overlapPathPickerCurrentPath
|
|
: `${overlapPathPickerCurrentPath}/`;
|
|
|
|
selectOverlapIgnorePath(directoryPath);
|
|
}, [overlapPathPickerCurrentPath, selectOverlapIgnorePath]);
|
|
|
|
const handleOverlapPathPickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
|
if (event.target === event.currentTarget) {
|
|
closeOverlapPathPicker();
|
|
}
|
|
}, [closeOverlapPathPicker]);
|
|
|
|
const openWorktreesDirPicker = useCallback(() => {
|
|
setWorktreesDirPickerPath(".");
|
|
setWorktreesDirPickerOpen(true);
|
|
}, [setWorktreesDirPickerPath]);
|
|
|
|
const closeWorktreesDirPicker = useCallback(() => {
|
|
setWorktreesDirPickerOpen(false);
|
|
}, []);
|
|
|
|
const selectWorktreesDirFromPicker = useCallback((path: string) => {
|
|
if (isSlashPrefixedAbsolutePath(path)) return;
|
|
|
|
const normalizedPath = path.endsWith("/") ? path : `${path}/`;
|
|
setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
|
|
closeWorktreesDirPicker();
|
|
}, [closeWorktreesDirPicker]);
|
|
|
|
const selectCurrentWorktreesDir = useCallback(() => {
|
|
if (isSlashPrefixedAbsolutePath(worktreesDirPickerCurrentPath)) return;
|
|
|
|
const normalizedPath = worktreesDirPickerCurrentPath === "."
|
|
? "./"
|
|
: (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`);
|
|
setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
|
|
closeWorktreesDirPicker();
|
|
}, [worktreesDirPickerCurrentPath, closeWorktreesDirPicker]);
|
|
|
|
const handleWorktreesDirPickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
|
if (event.target === event.currentTarget) {
|
|
closeWorktreesDirPicker();
|
|
}
|
|
}, [closeWorktreesDirPicker]);
|
|
|
|
const openWorktreeCopyFilePicker = useCallback((index: number) => {
|
|
setWorktreeCopyFilePickerIndex(index);
|
|
setWorktreeCopyFilePickerPath(".");
|
|
}, [setWorktreeCopyFilePickerPath]);
|
|
|
|
const closeWorktreeCopyFilePicker = useCallback(() => {
|
|
setWorktreeCopyFilePickerIndex(null);
|
|
}, []);
|
|
|
|
const selectWorktreeCopyFile = useCallback((path: string) => {
|
|
if (worktreeCopyFilePickerIndex === null) return;
|
|
|
|
/*
|
|
FNXC:FileBrowserAbsolutePaths 2026-06-29-00:00:
|
|
worktreeCopyFiles are copied from the project workspace into task worktrees. Keep this picker project-relative even when the standalone Files browser can browse slash-prefixed absolute paths.
|
|
*/
|
|
if (isSlashPrefixedAbsolutePath(path)) return;
|
|
|
|
setForm((f) => {
|
|
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
|
|
? [...f.worktreeCopyFiles]
|
|
: [""];
|
|
currentPaths[worktreeCopyFilePickerIndex] = path;
|
|
return { ...f, worktreeCopyFiles: currentPaths };
|
|
});
|
|
|
|
closeWorktreeCopyFilePicker();
|
|
}, [worktreeCopyFilePickerIndex, closeWorktreeCopyFilePicker]);
|
|
|
|
const handleWorktreeCopyFilePickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
|
if (event.target === event.currentTarget) {
|
|
closeWorktreeCopyFilePicker();
|
|
}
|
|
}, [closeWorktreeCopyFilePicker]);
|
|
|
|
const handleWorktreeCopyFileChange = useCallback((index: number, value: string) => {
|
|
setForm((f) => {
|
|
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
|
|
? [...f.worktreeCopyFiles]
|
|
: [""];
|
|
currentPaths[index] = value;
|
|
return { ...f, worktreeCopyFiles: currentPaths };
|
|
});
|
|
}, []);
|
|
|
|
const handleRemoveWorktreeCopyFile = useCallback((index: number) => {
|
|
setForm((f) => {
|
|
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
|
|
? [...f.worktreeCopyFiles]
|
|
: [""];
|
|
const nextPaths = currentPaths.filter((_, i) => i !== index);
|
|
return { ...f, worktreeCopyFiles: nextPaths.length > 0 ? nextPaths : [] };
|
|
});
|
|
|
|
if (worktreeCopyFilePickerIndex === index) {
|
|
closeWorktreeCopyFilePicker();
|
|
return;
|
|
}
|
|
|
|
if (worktreeCopyFilePickerIndex !== null && worktreeCopyFilePickerIndex > index) {
|
|
setWorktreeCopyFilePickerIndex(worktreeCopyFilePickerIndex - 1);
|
|
}
|
|
}, [worktreeCopyFilePickerIndex, closeWorktreeCopyFilePicker]);
|
|
|
|
const handleAddWorktreeCopyFile = useCallback(() => {
|
|
setForm((f) => {
|
|
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
|
|
? f.worktreeCopyFiles
|
|
: [""];
|
|
return { ...f, worktreeCopyFiles: [...currentPaths, ""] };
|
|
});
|
|
}, []);
|
|
|
|
const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => {
|
|
setForm((f) => {
|
|
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
|
? [...f.overlapIgnorePaths]
|
|
: [""];
|
|
currentPaths[index] = value;
|
|
return { ...f, overlapIgnorePaths: currentPaths };
|
|
});
|
|
}, []);
|
|
|
|
const handleRemoveOverlapIgnorePath = useCallback((index: number) => {
|
|
setForm((f) => {
|
|
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
|
? [...f.overlapIgnorePaths]
|
|
: [""];
|
|
const nextPaths = currentPaths.filter((_, i) => i !== index);
|
|
return { ...f, overlapIgnorePaths: nextPaths.length > 0 ? nextPaths : [] };
|
|
});
|
|
|
|
if (overlapPathPickerIndex === index) {
|
|
closeOverlapPathPicker();
|
|
return;
|
|
}
|
|
|
|
if (overlapPathPickerIndex !== null && overlapPathPickerIndex > index) {
|
|
setOverlapPathPickerIndex(overlapPathPickerIndex - 1);
|
|
}
|
|
}, [overlapPathPickerIndex, closeOverlapPathPicker]);
|
|
|
|
const handleAddOverlapIgnorePath = useCallback(() => {
|
|
setForm((f) => {
|
|
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
|
? f.overlapIgnorePaths
|
|
: [""];
|
|
return { ...f, overlapIgnorePaths: [...currentPaths, ""] };
|
|
});
|
|
}, []);
|
|
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-02-12:00:
|
|
FN-8395 implements issue #2343 Option 1: form-backed Settings persist through
|
|
this debounced single-flight path, never a Save button or dirty-leave prompt.
|
|
Each request works from a captured render snapshot and advances only matching
|
|
baselines, so an older response cannot erase a newer edit.
|
|
*/
|
|
const persistSettings = useCallback(async (): Promise<boolean> => {
|
|
if (persistInFlightRef.current) {
|
|
trailingPersistRef.current = true;
|
|
return false;
|
|
}
|
|
if (prefixError || presetDraft) {
|
|
lastPersistSucceededRef.current = false;
|
|
return false;
|
|
}
|
|
|
|
const formSnapshot = form;
|
|
const scopedSettingsSnapshot = scopedSettings;
|
|
const initialValuesSnapshot = initialValues;
|
|
const initialScopedValuesSnapshot = initialScopedValues;
|
|
const activeSectionSnapshot = activeSection;
|
|
const globalGitlabSettingsSnapshot = globalGitlabSettings;
|
|
const workflowLaneRevisionSnapshot = workflowLaneRevisionRef.current;
|
|
const limits = formSnapshot.researchSettings?.limits;
|
|
if (limits?.maxConcurrentRuns !== undefined && (!Number.isFinite(limits.maxConcurrentRuns) || limits.maxConcurrentRuns < 1)) {
|
|
setResearchLimitError("Research max concurrent runs must be at least 1.");
|
|
lastPersistSucceededRef.current = false;
|
|
return false;
|
|
}
|
|
if (limits?.maxSourcesPerRun !== undefined && (!Number.isFinite(limits.maxSourcesPerRun) || limits.maxSourcesPerRun < 1)) {
|
|
setResearchLimitError("Research max sources per run must be at least 1.");
|
|
lastPersistSucceededRef.current = false;
|
|
return false;
|
|
}
|
|
if (limits?.maxDurationMs !== undefined && (!Number.isFinite(limits.maxDurationMs) || limits.maxDurationMs < 1000)) {
|
|
setResearchLimitError("Research max duration must be at least 1000 ms.");
|
|
lastPersistSucceededRef.current = false;
|
|
return false;
|
|
}
|
|
if (limits?.requestTimeoutMs !== undefined && (!Number.isFinite(limits.requestTimeoutMs) || limits.requestTimeoutMs < 1000)) {
|
|
setResearchLimitError("Research request timeout must be at least 1000 ms.");
|
|
lastPersistSucceededRef.current = false;
|
|
return false;
|
|
}
|
|
setResearchLimitError(null);
|
|
|
|
const shortcutValidationError = describeShortcutValidation(formSnapshot.dashboardKeyboardShortcuts ?? {});
|
|
if (shortcutValidationError) {
|
|
addToast(shortcutValidationError, "error");
|
|
lastPersistSucceededRef.current = false;
|
|
return false;
|
|
}
|
|
|
|
persistInFlightRef.current = true;
|
|
setIsSaving(true);
|
|
setAutoSaveStatus("saving");
|
|
try {
|
|
const normalizedWorktreeCopyFiles = normalizeWorktreeCopyFilesForSave(formSnapshot.worktreeCopyFiles);
|
|
/*
|
|
FNXC:WindowsTerminalStartup 2026-07-04-06:30:
|
|
Worktrunk status is now only auto-probed once the integration is enabled, so a
|
|
user who toggles worktrunk on and hits Save before the probe returns would read a
|
|
stale `worktrunkInstallVerified === false` and silently persist `enabled: false`,
|
|
discarding their opt-in. When enabling but not yet verified, await one definitive
|
|
probe (safe: the engine guard refuses to launch Windows Terminal) and clamp on
|
|
that fresh result instead.
|
|
*/
|
|
let worktrunkVerifiedForSave = worktrunkInstallVerified;
|
|
if (formSnapshot.worktrunk?.enabled === true && !worktrunkVerifiedForSave) {
|
|
const freshWorktrunkStatus = await worktrunkInstall.refresh();
|
|
worktrunkVerifiedForSave = freshWorktrunkStatus.status === "installed";
|
|
}
|
|
/*
|
|
FNXC:GitLabEnablement 2026-07-02-00:00:
|
|
The global source-control section must edit raw global GitLab settings, not the merged project-effective formSnapshot. 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 = activeSectionSnapshot === "source-control-global" && globalGitlabSettingsSnapshot ? globalGitlabSettingsSnapshot : formSnapshot;
|
|
const payload = {
|
|
...formSnapshot,
|
|
worktreeInitCommand: formSnapshot.worktreeInitCommand?.trim() || undefined,
|
|
worktreesDir: formSnapshot.worktreesDir?.trim() || undefined,
|
|
worktrunk: {
|
|
enabled: worktrunkVerifiedForSave && formSnapshot.worktrunk?.enabled === true,
|
|
binaryPath: formSnapshot.worktrunk?.binaryPath?.trim() || undefined,
|
|
onFailure: formSnapshot.worktrunk?.onFailure ?? "fail",
|
|
},
|
|
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(formSnapshot),
|
|
executorToolFailureRetryCount: resolveNonNegativeExecutorToolFailureSetting(formSnapshot.executorToolFailureRetryCount, 2),
|
|
executorToolFailureRetryBackoffMs: resolveNonNegativeExecutorToolFailureSetting(formSnapshot.executorToolFailureRetryBackoffMs, 2000),
|
|
executorToolFailureThreshold: Math.max(1, Math.floor(Number(formSnapshot.executorToolFailureThreshold ?? 3) || 3)),
|
|
executorModelEscalationEnabled: formSnapshot.executorModelEscalationEnabled === true,
|
|
executorEscalationProvider: formSnapshot.executorEscalationProvider?.trim() || undefined,
|
|
executorEscalationModelId: formSnapshot.executorEscalationModelId?.trim() || undefined,
|
|
executorEscalationNodeId: formSnapshot.executorEscalationNodeId?.trim() || undefined,
|
|
taskPrefix: formSnapshot.taskPrefix?.trim() || undefined,
|
|
githubTrackingDefaultRepo: formSnapshot.githubTrackingDefaultRepo?.trim() || undefined,
|
|
/*
|
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
|
FN-7553 normalizes every declared shortcut action (derived from resolveDashboardKeyboardShortcuts' key set) on save, not just quickChat/terminal, so newly-added actions get the same trim/normalize-before-persist treatment.
|
|
*/
|
|
dashboardKeyboardShortcuts: Object.fromEntries(
|
|
(Object.entries(resolveDashboardKeyboardShortcuts(formSnapshot.dashboardKeyboardShortcuts)) as [DashboardShortcutAction, string][])
|
|
.map(([action, shortcut]) => [action, normalizeKeyboardShortcut(shortcut).normalized]),
|
|
) as DashboardKeyboardShortcutMap,
|
|
gitlabEnabled: gitlabFormForSave.gitlabEnabled,
|
|
gitlabInstanceUrl: gitlabFormForSave.gitlabInstanceUrl?.trim() || undefined,
|
|
gitlabApiBaseUrl: gitlabFormForSave.gitlabApiBaseUrl?.trim() || undefined,
|
|
gitlabAuthToken: gitlabFormForSave.gitlabAuthToken?.trim() || undefined,
|
|
gitlabAuthTokenType: gitlabFormForSave.gitlabAuthTokenType ?? "personal",
|
|
reportRoadmapDedupeEnabled: gitlabFormForSave.reportRoadmapDedupeEnabled,
|
|
reportRoadmapLabel: gitlabFormForSave.reportRoadmapLabel?.trim() || undefined,
|
|
reportRoadmapRepo: gitlabFormForSave.reportRoadmapRepo?.trim() || undefined,
|
|
githubAuthToken: formSnapshot.githubAuthToken?.trim() || undefined,
|
|
prTitlePromptInstructions: formSnapshot.prTitlePromptInstructions?.trim() || undefined,
|
|
prDescriptionPromptInstructions: formSnapshot.prDescriptionPromptInstructions?.trim() || undefined,
|
|
/*
|
|
FNXC:MergeSettings 2026-07-04-09:18:
|
|
Push target text is meaningful only when direct post-merge pushing is enabled. Hiding the input must not keep submitting a stale remote/branch from the form state; clearing it lets project settings fall back to the default origin target when the toggle is disabled.
|
|
*/
|
|
pushRemote: formSnapshot.pushAfterMerge ? formSnapshot.pushRemote?.trim() || undefined : undefined,
|
|
overlapIgnorePaths: (formSnapshot.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
|
|
worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValuesSnapshot?.project?.worktreeCopyFiles !== undefined
|
|
? normalizedWorktreeCopyFiles
|
|
: undefined,
|
|
experimentalFeatures: normalizeExperimentalFeaturesForSave(formSnapshot.experimentalFeatures),
|
|
};
|
|
|
|
// FNXC:SourceControl 2026-07-15-20:30: Both GitLab URL-cache refreshes follow their editing sections ("general"/"global-general" before the move).
|
|
if (activeSectionSnapshot === "source-control") {
|
|
resolveGitlabConfig({
|
|
project: {
|
|
gitlabInstanceUrl: payload.gitlabInstanceUrl,
|
|
gitlabApiBaseUrl: payload.gitlabApiBaseUrl,
|
|
},
|
|
});
|
|
}
|
|
if (activeSectionSnapshot === "source-control-global") {
|
|
resolveGitlabConfig({
|
|
global: {
|
|
gitlabInstanceUrl: payload.gitlabInstanceUrl,
|
|
gitlabApiBaseUrl: payload.gitlabApiBaseUrl,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Always save both global and project settings with strict scope
|
|
// separation. The split (global vs project routing, null-as-delete, and
|
|
// changed-only project writes) lives in the pure `splitSettingsSave`
|
|
// helper so the regression-critical behavior is characterized in
|
|
// isolation; see settings/save-split.ts.
|
|
const { globalPatch, projectPatch } = splitSettingsSave({
|
|
payload,
|
|
initialValues: initialValuesSnapshot,
|
|
initialScopedValues: initialScopedValuesSnapshot,
|
|
activeSection: activeSectionSnapshot,
|
|
scopedMcpValues: scopedSettingsSnapshot ? {
|
|
global: resolveScopedMcpSettings("global", scopedSettingsSnapshot),
|
|
project: resolveScopedMcpSettings("project", scopedSettingsSnapshot),
|
|
} : undefined,
|
|
});
|
|
|
|
// Save both scopes in parallel if they have changes.
|
|
// Note: themeMode/colorTheme may also be write-through via useTheme callbacks
|
|
// in the Appearance section; duplicate global writes are intentional/idempotent,
|
|
// while this save path persists the full settings form in one action.
|
|
await Promise.all([
|
|
Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(),
|
|
Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(),
|
|
]);
|
|
|
|
await workflowLaneSaverRef.current?.();
|
|
|
|
// Only clear workflow-lane dirtiness when no newer lane edit arrived.
|
|
if (workflowLaneRevisionRef.current === workflowLaneRevisionSnapshot) {
|
|
setWorkflowLanesDirty(false);
|
|
}
|
|
|
|
// Quiet state feedback avoids a toast for each debounced edit.
|
|
setAutoSaveStatus("saved");
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-02-20:50:
|
|
A completed request may describe an older form snapshot. Advance only the
|
|
keys that request actually wrote so a response can never bless unrelated,
|
|
newer edits as already persisted.
|
|
*/
|
|
setInitialValues((current) => current ? { ...current, ...globalPatch } : current);
|
|
setInitialScopedValues((current) => {
|
|
if (!current) return current;
|
|
const mergePatch = (base: Record<string, unknown>, patch: Record<string, unknown>) => Object.fromEntries(
|
|
Object.entries({ ...base, ...patch }).map(([key, value]) => [key, value === null ? undefined : value]),
|
|
);
|
|
return {
|
|
global: mergePatch(current.global as Record<string, unknown>, globalPatch as Record<string, unknown>) as GlobalSettings,
|
|
project: mergePatch(current.project, projectPatch as Record<string, unknown>) as Partial<Settings>,
|
|
};
|
|
});
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-02-21:45:
|
|
A successful snapshot becomes the next autosave comparison point. If the
|
|
user edited while this request was in flight, the live snapshot differs
|
|
and the effect queues exactly one trailing write.
|
|
*/
|
|
autoSaveActivationSnapshotRef.current = JSON.stringify({
|
|
form: formSnapshot,
|
|
scopedSettings: scopedSettingsSnapshot,
|
|
globalGitlabSettings: globalGitlabSettingsSnapshot,
|
|
});
|
|
lastPersistSucceededRef.current = true;
|
|
return true;
|
|
} catch (err) {
|
|
lastPersistSucceededRef.current = false;
|
|
if (err instanceof WorkflowLaneFlushRejection) return false;
|
|
setAutoSaveStatus("error");
|
|
addToast(getErrorMessage(err), "error");
|
|
return false;
|
|
} finally {
|
|
persistInFlightRef.current = false;
|
|
setIsSaving(false);
|
|
if (trailingPersistRef.current) {
|
|
trailingPersistRef.current = false;
|
|
void persistSettingsRef.current?.();
|
|
}
|
|
}
|
|
}, [form, globalGitlabSettings, prefixError, presetDraft, initialValues, initialScopedValues, scopedSettings, addToast, projectId, activeSection, t]);
|
|
|
|
persistSettingsRef.current = persistSettings;
|
|
const settingsDirty = useMemo(() => {
|
|
const dirtyPayload = activeSection === "source-control-global" && globalGitlabSettings
|
|
? { ...form, ...globalGitlabSettings }
|
|
: form;
|
|
const { globalPatch, projectPatch } = splitSettingsSave({
|
|
payload: dirtyPayload,
|
|
initialValues,
|
|
initialScopedValues,
|
|
activeSection,
|
|
scopedMcpValues: scopedSettings ? {
|
|
global: resolveScopedMcpSettings("global", scopedSettings),
|
|
project: resolveScopedMcpSettings("project", scopedSettings),
|
|
} : undefined,
|
|
});
|
|
return Object.keys(globalPatch).length > 0 || Object.keys(projectPatch).length > 0
|
|
|| workflowLanesDirty;
|
|
}, [form, globalGitlabSettings, initialScopedValues, initialValues, scopedSettings, activeSection, workflowLanesDirty]);
|
|
|
|
const autoSaveSnapshot = useMemo(() => JSON.stringify({ form, scopedSettings, globalGitlabSettings, workflowLaneRevision: workflowLaneRevisionRef.current }), [form, globalGitlabSettings, scopedSettings, workflowLanesDirty]);
|
|
const hasAutoSaveChange = autoSaveActivationSnapshotRef.current !== null
|
|
&& autoSaveActivationSnapshotRef.current !== autoSaveSnapshot;
|
|
latestAutoSaveStateRef.current = { dirty: settingsDirty, changed: hasAutoSaveChange };
|
|
|
|
useEffect(() => {
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-02-21:35:
|
|
Some legacy form values are normalized differently from their raw scoped
|
|
settings. Snapshot the hydrated form before enabling autosave so opening
|
|
Settings cannot write those untouched defaults; later user edits change the
|
|
snapshot and are persisted through the normal dirty split.
|
|
*/
|
|
if (!loading && autoSaveActivationSnapshotRef.current === null) {
|
|
autoSaveActivationSnapshotRef.current = autoSaveSnapshot;
|
|
setAutoSaveReady(true);
|
|
}
|
|
}, [autoSaveSnapshot, loading]);
|
|
|
|
useEffect(() => {
|
|
if (loading || !autoSaveReady || !hasAutoSaveChange || !settingsDirty || prefixError || presetDraft) return;
|
|
if (persistInFlightRef.current) {
|
|
trailingPersistRef.current = true;
|
|
return;
|
|
}
|
|
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = setTimeout(() => {
|
|
autoSaveTimerRef.current = null;
|
|
void persistSettingsRef.current?.();
|
|
}, 500);
|
|
return () => {
|
|
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
};
|
|
}, [loading, autoSaveReady, hasAutoSaveChange, settingsDirty, prefixError, presetDraft, form, scopedSettings, globalGitlabSettings, workflowLanesDirty, activeSection]);
|
|
|
|
const requestClose = useCallback(async () => {
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = null;
|
|
}
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-02-20:50:
|
|
Close is never a discard path. If a request is already running, queue its
|
|
latest trailing snapshot and wait for that queue to drain before the modal
|
|
unmounts; otherwise flush the current dirty snapshot synchronously.
|
|
*/
|
|
if (persistInFlightRef.current) {
|
|
trailingPersistRef.current = true;
|
|
} else if (settingsDirty && hasAutoSaveChange) {
|
|
await persistSettingsRef.current?.();
|
|
}
|
|
while (persistInFlightRef.current || trailingPersistRef.current) {
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
// A failed/validation-blocked flush must leave Settings open for correction.
|
|
if (!lastPersistSucceededRef.current) return;
|
|
onClose();
|
|
}, [onClose, settingsDirty, hasAutoSaveChange]);
|
|
|
|
const requestSectionChange = useCallback(async (sectionId: SectionId) => {
|
|
if (sectionId === activeSection) return;
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = null;
|
|
}
|
|
if ((settingsDirty && hasAutoSaveChange) || persistInFlightRef.current) {
|
|
if (persistInFlightRef.current) {
|
|
trailingPersistRef.current = true;
|
|
while (persistInFlightRef.current || trailingPersistRef.current) {
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
} else {
|
|
const persisted = await persistSettingsRef.current?.();
|
|
if (!persisted) return;
|
|
}
|
|
// Do not re-scope a failed raw-global edit after navigating away from it.
|
|
if (!lastPersistSucceededRef.current) return;
|
|
}
|
|
setActiveSection(sectionId);
|
|
}, [activeSection, settingsDirty, hasAutoSaveChange]);
|
|
|
|
requestSectionChangeRef.current = (sectionId) => { void requestSectionChange(sectionId); };
|
|
|
|
useEffect(() => () => {
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
autoSaveTimerRef.current = null;
|
|
}
|
|
/*
|
|
FNXC:SettingsAutoSave 2026-08-03-22:15:
|
|
Parent-driven unmount is also a dismissal path. Retain a dirty snapshot's
|
|
flush even when a debounce timer is not present at cleanup, rather than
|
|
treating the timer itself as the source of durability.
|
|
*/
|
|
if (!persistInFlightRef.current && latestAutoSaveStateRef.current.dirty && latestAutoSaveStateRef.current.changed) {
|
|
// The ref always points at the latest render snapshot, even during unmount.
|
|
void persistSettingsRef.current?.();
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!escapeEnabled) return;
|
|
const handleKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && !resetDialogOpen) void requestClose();
|
|
};
|
|
document.addEventListener("keydown", handleKey);
|
|
return () => document.removeEventListener("keydown", handleKey);
|
|
}, [escapeEnabled, requestClose, resetDialogOpen]);
|
|
|
|
|
|
/*
|
|
FNXC:SettingsReset 2026-07-04-00:25:
|
|
"Reset this menu" resolves the active section's { scope, keys } from the
|
|
shared section-keys registry (packages/dashboard/app/components/settings/section-keys.ts)
|
|
and writes ONLY those keys, at the correct scope, through the SAME
|
|
updateGlobalSettings/updateSettings plumbing (and null-as-delete convention)
|
|
used by handleSave/splitSettingsSave. GLOBAL keys reset to the canonical
|
|
DEFAULT_GLOBAL_SETTINGS value; PROJECT keys reset via null-as-delete so an
|
|
overridable project setting reverts to its inherited/default value. The form
|
|
is refreshed afterward via refreshSettingsForm so fields immediately show the
|
|
reset values.
|
|
*/
|
|
const activeSectionResetEntry = useMemo(() => getSectionKeyEntry(activeSection), [activeSection]);
|
|
const activeSectionResetIneligibleReason = useMemo(() => getResetIneligibleReason(activeSection), [activeSection]);
|
|
const activeSectionLabel = useMemo(() => {
|
|
const section = SETTINGS_SECTIONS.find((s) => s.id === activeSection);
|
|
return section ? t(section.labelKey, section.label) : activeSection;
|
|
}, [activeSection, t]);
|
|
|
|
const handleResetActiveSection = useCallback(async () => {
|
|
if (resetInFlight || !activeSectionResetEntry) return;
|
|
setResetInFlight(true);
|
|
try {
|
|
if (activeSectionResetEntry.scope === "global") {
|
|
const patch: Record<string, unknown> = {};
|
|
for (const key of activeSectionResetEntry.keys) {
|
|
/*
|
|
FNXC:SettingsReset 2026-07-22-23:55:
|
|
Issue #2411: global keys whose canonical default is undefined (e.g.
|
|
embeddedPostgresMaxConnections, resolved platform-aware server-side)
|
|
must reset via null-as-delete. A literal undefined is dropped by JSON
|
|
serialization, so the stored value would silently survive the reset.
|
|
*/
|
|
patch[key] = (DEFAULT_GLOBAL_SETTINGS as Record<string, unknown>)[key] ?? null;
|
|
}
|
|
await updateGlobalSettings(patch);
|
|
} else {
|
|
const patch: Record<string, unknown> = {};
|
|
for (const key of activeSectionResetEntry.keys) {
|
|
patch[key] = null; // null-as-delete: revert to inherited/default project value
|
|
}
|
|
await updateSettings(patch, projectId);
|
|
}
|
|
await refreshSettingsForm(false);
|
|
addToast(t("settings.reset.menuResetSuccess", "{{section}} settings reset to defaults", { section: activeSectionLabel }), "success");
|
|
setResetDialogOpen(false);
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err), "error");
|
|
} finally {
|
|
setResetInFlight(false);
|
|
}
|
|
}, [resetInFlight, activeSectionResetEntry, projectId, refreshSettingsForm, addToast, t, activeSectionLabel]);
|
|
|
|
const handleResetAllProjectSettings = useCallback(async () => {
|
|
if (resetInFlight) return;
|
|
setResetInFlight(true);
|
|
try {
|
|
const patch: Record<string, unknown> = {};
|
|
for (const key of ALL_PROJECT_RESET_KEYS) {
|
|
patch[key] = null; // null-as-delete: never touches global keys
|
|
}
|
|
await updateSettings(patch, projectId);
|
|
await refreshSettingsForm(false);
|
|
addToast(t("settings.reset.allProjectResetSuccess", "All project settings reset to defaults"), "success");
|
|
setResetDialogOpen(false);
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err), "error");
|
|
} finally {
|
|
setResetInFlight(false);
|
|
}
|
|
}, [resetInFlight, projectId, refreshSettingsForm, addToast, t]);
|
|
|
|
const closeResetDialog = useCallback(() => {
|
|
if (resetInFlight) return;
|
|
setResetDialogOpen(false);
|
|
}, [resetInFlight]);
|
|
|
|
const handleResetDialogOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
|
if (event.target === event.currentTarget) {
|
|
closeResetDialog();
|
|
}
|
|
}, [closeResetDialog]);
|
|
|
|
// Reset dialog gets its own Escape handler (takes precedence over the modal-level
|
|
// Escape-to-close so Escape closes only the confirmation dialog, not the whole modal).
|
|
useEffect(() => {
|
|
if (!resetDialogOpen) return;
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
closeResetDialog();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKey, true);
|
|
return () => document.removeEventListener("keydown", handleKey, true);
|
|
}, [resetDialogOpen, closeResetDialog]);
|
|
|
|
const handleSaveMemory = useCallback(async () => {
|
|
try {
|
|
await saveMemoryFile(selectedMemoryPath, memoryContent, projectId);
|
|
setMemoryDirty(false);
|
|
addToast(t("settings.memory.memorySaved", "Memory saved"), "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to save memory", "error");
|
|
}
|
|
}, [selectedMemoryPath, memoryContent, projectId, addToast]);
|
|
|
|
const handleCompactMemory = useCallback(async () => {
|
|
setMemoryCompactLoading(true);
|
|
try {
|
|
const { path, content } = await compactMemory(selectedMemoryPath, projectId);
|
|
const nextPath = path ?? selectedMemoryPath;
|
|
if (selectedMemoryPath !== nextPath) {
|
|
skipNextMemoryReloadRef.current = true;
|
|
}
|
|
setSelectedMemoryPath(nextPath);
|
|
setMemoryContent(content);
|
|
setMemoryDirty(false);
|
|
|
|
const { files } = await fetchMemoryFiles(projectId);
|
|
setMemoryFiles(files);
|
|
|
|
addToast(t("settings.memory.memoryCompacted", "Memory file compacted"), "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to compact memory", "error");
|
|
} finally {
|
|
setMemoryCompactLoading(false);
|
|
}
|
|
}, [selectedMemoryPath, projectId, addToast]);
|
|
|
|
const handleTestMemoryRetrieval = useCallback(async () => {
|
|
setMemoryTestLoading(true);
|
|
setMemoryTestResult(null);
|
|
try {
|
|
const result = await testMemoryRetrieval(memoryTestQuery, projectId);
|
|
setMemoryTestResult(result);
|
|
addToast(
|
|
result.qmdAvailable ? "Memory retrieval test complete" : "qmd is not installed; local fallback was used",
|
|
result.qmdAvailable ? "success" : "warning",
|
|
);
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to test memory retrieval", "error");
|
|
} finally {
|
|
setMemoryTestLoading(false);
|
|
}
|
|
}, [memoryTestQuery, projectId, addToast]);
|
|
|
|
const handleDreamNow = useCallback(async () => {
|
|
setDreamRunning(true);
|
|
try {
|
|
await triggerMemoryDreams(projectId);
|
|
addToast(t("settings.memory.dreamCompleted", "Dream processing completed"), "success");
|
|
} catch (error) {
|
|
addToast(error instanceof Error ? error.message : "Failed to run dream processing", "error");
|
|
} finally {
|
|
setDreamRunning(false);
|
|
}
|
|
}, [projectId, addToast]);
|
|
|
|
const handleInstallQmd = useCallback(async () => {
|
|
setQmdInstallLoading(true);
|
|
try {
|
|
const result = await installQmd(projectId);
|
|
await refreshMemoryBackend();
|
|
addToast(
|
|
result.qmdAvailable ? t("settings.memory.qmdInstalled", "qmd installed successfully") : t("settings.memory.qmdInstallUnavailable", "qmd install finished, but qmd is still unavailable"),
|
|
result.qmdAvailable ? "success" : "warning",
|
|
);
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || t("settings.memory.qmdInstallFailed", "Failed to install qmd"), "error");
|
|
} finally {
|
|
setQmdInstallLoading(false);
|
|
}
|
|
}, [projectId, refreshMemoryBackend, addToast]);
|
|
|
|
const savePresetDraft = () => {
|
|
if (!presetDraft) return;
|
|
|
|
const nextName = presetDraft.name.trim();
|
|
if (!nextName) {
|
|
addToast(t("settings.models.presetNameRequired", "Preset name is required"), "error");
|
|
return;
|
|
}
|
|
|
|
const presets = form.modelPresets || [];
|
|
|
|
// For new presets, generate unique ID from name; for edits, keep existing ID
|
|
let nextId: string;
|
|
if (editingPresetId) {
|
|
nextId = editingPresetId;
|
|
} else {
|
|
nextId = generateUniquePresetId(nextName, presets);
|
|
}
|
|
|
|
const normalizedDraft: ModelPreset = {
|
|
id: nextId,
|
|
name: nextName,
|
|
executorProvider: presetDraft.executorProvider,
|
|
executorModelId: presetDraft.executorModelId,
|
|
validatorProvider: presetDraft.validatorProvider,
|
|
validatorModelId: presetDraft.validatorModelId,
|
|
};
|
|
|
|
setForm((current) => {
|
|
const existing = current.modelPresets || [];
|
|
const nextPresets = editingPresetId
|
|
? existing.map((preset) => (preset.id === editingPresetId ? normalizedDraft : preset))
|
|
: [...existing, normalizedDraft];
|
|
return { ...current, modelPresets: nextPresets };
|
|
});
|
|
|
|
setEditingPresetId(null);
|
|
setPresetDraft(null);
|
|
};
|
|
|
|
const runRemoteAction = useCallback(async (label: string, action: () => Promise<void>) => {
|
|
setRemoteBusyAction(label);
|
|
try {
|
|
await action();
|
|
await loadRemoteData();
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || `Failed to ${label}`, "error");
|
|
} finally {
|
|
setRemoteBusyAction(null);
|
|
}
|
|
}, [addToast, loadRemoteData]);
|
|
|
|
const cloudflaredManualInstallCommand = useCallback(() => {
|
|
if (typeof navigator !== "undefined" && navigator.userAgent.includes("Windows")) {
|
|
return "winget install Cloudflare.cloudflared";
|
|
}
|
|
|
|
const platform = typeof navigator !== "undefined" ? navigator.platform : "";
|
|
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
const isMac = /(Mac|iPhone|iPad|iPod)/i.test(platform);
|
|
const isArm = /(arm64|aarch64)/i.test(`${platform} ${userAgent}`);
|
|
|
|
if (isMac) {
|
|
return "brew install cloudflared";
|
|
}
|
|
|
|
const linuxArch = isArm ? "arm64" : "amd64";
|
|
return `curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${linuxArch} && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared # If sudo is unavailable, use: mkdir -p ~/.local/bin && mv /tmp/cloudflared ~/.local/bin/cloudflared`;
|
|
}, []);
|
|
|
|
const cloudflaredMacFallbackCommand = useCallback(() => {
|
|
if (typeof navigator === "undefined") {
|
|
return null;
|
|
}
|
|
if (!/(Mac|iPhone|iPad|iPod)/i.test(navigator.platform)) {
|
|
return null;
|
|
}
|
|
|
|
const arch = /(arm64|aarch64)/i.test(`${navigator.platform} ${navigator.userAgent}`) ? "arm64" : "amd64";
|
|
return `curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${arch} && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/cloudflared`;
|
|
}, []);
|
|
|
|
const handleInstallCloudflared = useCallback(async () => {
|
|
setCloudflaredInstalling(true);
|
|
setCloudflaredInstallError(null);
|
|
try {
|
|
const result = await installCloudflared(projectId);
|
|
if (!result.success) {
|
|
setCloudflaredInstallError(result.error ?? t("settings.remote.installationFailed", "Installation failed"));
|
|
return;
|
|
}
|
|
const status = await fetchRemoteStatus(projectId);
|
|
setRemoteStatus(status);
|
|
addToast(t("settings.remote.cloudflaredInstalled", "cloudflared installed successfully"), "success");
|
|
} catch (err) {
|
|
setCloudflaredInstallError(err instanceof Error ? err.message : t("settings.remote.installationFailed", "Installation failed"));
|
|
} finally {
|
|
setCloudflaredInstalling(false);
|
|
}
|
|
}, [addToast, projectId]);
|
|
|
|
const renderSectionFields = () => {
|
|
switch (activeSection) {
|
|
case "cli-agents":
|
|
return <CliAgentsSettingsSection projectId={projectId} addToast={addToast} />;
|
|
case "general":
|
|
return (
|
|
<GeneralSection
|
|
form={form}
|
|
setForm={setForm}
|
|
projectId={projectId}
|
|
addToast={addToast}
|
|
prefixError={prefixError}
|
|
setPrefixError={setPrefixError}
|
|
onQuickChatButtonModeChange={onQuickChatButtonModeChange}
|
|
onMobileNavPrimaryItemsChange={onMobileNavPrimaryItemsChange}
|
|
/>
|
|
);
|
|
case "source-control":
|
|
return (
|
|
<SourceControlSection
|
|
form={form}
|
|
setForm={setForm}
|
|
projectTrackingRepoOptions={projectTrackingRepoOptions}
|
|
projectTrackingRepoLoading={projectTrackingRepoLoading}
|
|
projectTrackingRepoError={projectTrackingRepoError}
|
|
/>
|
|
);
|
|
case "source-control-global":
|
|
return (
|
|
<SourceControlGlobalSection
|
|
form={form}
|
|
setForm={setForm}
|
|
globalSettings={globalGitlabSettings}
|
|
onGlobalSourceControlSettingsChange={(patch) => setGlobalGitlabSettings((current) => ({
|
|
gitlabEnabled: current?.gitlabEnabled,
|
|
gitlabInstanceUrl: current?.gitlabInstanceUrl,
|
|
gitlabApiBaseUrl: current?.gitlabApiBaseUrl,
|
|
gitlabAuthToken: current?.gitlabAuthToken,
|
|
gitlabAuthTokenType: current?.gitlabAuthTokenType,
|
|
reportRoadmapDedupeEnabled: current?.reportRoadmapDedupeEnabled,
|
|
reportRoadmapLabel: current?.reportRoadmapLabel,
|
|
reportRoadmapRepo: current?.reportRoadmapRepo,
|
|
...patch,
|
|
}))}
|
|
globalTrackingRepoOptions={globalTrackingRepoOptions}
|
|
globalTrackingRepoLoading={globalTrackingRepoLoading}
|
|
globalTrackingRepoError={globalTrackingRepoError}
|
|
/>
|
|
);
|
|
case "global-general":
|
|
return (
|
|
<GlobalGeneralSection
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "keyboard-shortcuts":
|
|
return (
|
|
<KeyboardShortcutsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "global-models":
|
|
return (
|
|
<GlobalModelsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
availableModels={availableModels}
|
|
modelsLoading={modelsLoading}
|
|
globalModelLanes={MODEL_LANES.filter((lane) => lane.laneId !== "default")}
|
|
getLaneThinkingValue={(lane) => getLaneThinkingValue(lane, "global")}
|
|
updateLaneThinkingValue={(lane, level) => updateLaneThinkingValue(lane, level, "global")}
|
|
resetLaneThinkingValue={(lane) => resetLaneThinkingValue(lane, "global")}
|
|
favoriteProviders={favoriteProviders}
|
|
favoriteModels={favoriteModels}
|
|
onToggleFavorite={handleToggleFavorite}
|
|
onToggleModelFavorite={handleToggleModelFavorite}
|
|
addToast={addToast}
|
|
projectId={projectId}
|
|
/>
|
|
);
|
|
|
|
case "secrets":
|
|
return <SecretsSection addToast={addToast} />;
|
|
case "global-mcp":
|
|
return (
|
|
<GlobalMcpSection
|
|
form={mcpFormForScope("global")}
|
|
setForm={setMcpFormForScope("global")}
|
|
projectId={projectId}
|
|
addToast={addToast}
|
|
/>
|
|
);
|
|
case "mcp":
|
|
return (
|
|
<ProjectMcpSection
|
|
form={mcpFormForScope("project")}
|
|
setForm={setMcpFormForScope("project")}
|
|
globalSettings={scopedSettings?.global ?? null}
|
|
projectId={projectId}
|
|
addToast={addToast}
|
|
/>
|
|
);
|
|
|
|
case "project-models":
|
|
return (
|
|
<ProjectModelsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
projectId={projectId}
|
|
addToast={addToast}
|
|
onOpenWorkflowSettings={onOpenWorkflowSettings}
|
|
registerWorkflowLaneSaver={registerWorkflowLaneSaver}
|
|
onWorkflowLanesChange={markWorkflowLanesDirty}
|
|
models={{
|
|
modelLanes: MODEL_LANES,
|
|
getLaneStatus,
|
|
getLaneValue,
|
|
updateLaneValue,
|
|
resetLaneValue,
|
|
getLaneThinkingValue,
|
|
updateLaneThinkingValue,
|
|
resetLaneThinkingValue,
|
|
availableModels,
|
|
modelsLoading,
|
|
favoriteProviders,
|
|
favoriteModels,
|
|
onToggleFavorite: handleToggleFavorite,
|
|
onToggleModelFavorite: handleToggleModelFavorite,
|
|
editingPresetId,
|
|
setEditingPresetId,
|
|
presetDraft,
|
|
setPresetDraft,
|
|
onSavePresetDraft: savePresetDraft,
|
|
confirmDelete: confirm,
|
|
}}
|
|
/>
|
|
);
|
|
case "appearance":
|
|
return (
|
|
<AppearanceSection
|
|
form={form}
|
|
setForm={setForm}
|
|
themeMode={themeMode}
|
|
colorTheme={colorTheme}
|
|
dashboardFontScalePct={dashboardFontScalePct}
|
|
shadcnCustomColors={shadcnCustomColors}
|
|
resolvedThemeMode={resolvedThemeMode}
|
|
onThemeModeChange={onThemeModeChange}
|
|
onColorThemeChange={onColorThemeChange}
|
|
onDashboardFontScaleChange={onDashboardFontScaleChange}
|
|
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
|
sessionBannersHidden={sessionBannersHidden}
|
|
setSessionBannersHidden={setSessionBannersHidden}
|
|
/>
|
|
);
|
|
case "scheduling":
|
|
return (
|
|
/*
|
|
FNXC:CapacityModel 2026-07-29-00:10 (drop the cross-project cap — settings half):
|
|
`concurrencyLoading` gated the PROJECT concurrency inputs on the GLOBAL
|
|
concurrency fetch, which was never the right source — maxConcurrent and
|
|
maxWorktrees come from the settings form. With the global cap deleted the gate
|
|
is repointed at the form's own load, preserving the invariant it existed for:
|
|
a concurrency input stays disabled until its live value has arrived, so an
|
|
operator cannot overwrite a resolved limit with a blank fallback.
|
|
*/
|
|
<SchedulingSection
|
|
form={form}
|
|
setForm={setForm}
|
|
concurrencyLoading={loading}
|
|
onOverlapIgnorePathChange={handleOverlapIgnorePathChange}
|
|
onOpenOverlapPathPicker={openOverlapPathPicker}
|
|
onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath}
|
|
onAddOverlapIgnorePath={handleAddOverlapIgnorePath}
|
|
onOpenWorkflowSettings={onOpenWorkflowSettings}
|
|
/>
|
|
);
|
|
case "scheduled-evals":
|
|
return (
|
|
<ScheduledEvalsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "node-routing":
|
|
return (
|
|
<NodeRoutingSection
|
|
form={form}
|
|
setForm={setForm}
|
|
nodes={nodes}
|
|
/>
|
|
);
|
|
case "worktrees":
|
|
return (
|
|
<WorktreesSection
|
|
form={form}
|
|
setForm={setForm}
|
|
gitRemotes={gitRemotes}
|
|
worktrunkInstall={worktrunkInstall}
|
|
worktrunkInstallVerified={worktrunkInstallVerified}
|
|
onOpenWorktreesDirPicker={openWorktreesDirPicker}
|
|
onWorktreeCopyFileChange={handleWorktreeCopyFileChange}
|
|
onRemoveWorktreeCopyFile={handleRemoveWorktreeCopyFile}
|
|
onAddWorktreeCopyFile={handleAddWorktreeCopyFile}
|
|
onOpenWorktreeCopyFilePicker={openWorktreeCopyFilePicker}
|
|
onOpenApprovals={onOpenApprovals}
|
|
/>
|
|
);
|
|
case "commands":
|
|
return (
|
|
<CommandsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "merge":
|
|
return (
|
|
<MergeSection
|
|
form={form}
|
|
setForm={setForm}
|
|
integrationBranchOptions={integrationBranchOptions}
|
|
integrationBranchCustomMode={integrationBranchCustomMode}
|
|
setIntegrationBranchCustomMode={setIntegrationBranchCustomMode}
|
|
onOpenWorkflowSettings={onOpenWorkflowSettings}
|
|
gitRemoteOptions={gitRemotes.map((r) => r.name)}
|
|
projectId={projectId}
|
|
/>
|
|
);
|
|
// FNXC:SettingsNavigation 2026-07-18-12:30: FN-8350 refreshes the Settings form after a rollback so restored values immediately replace the pre-rollback view.
|
|
case "config-versions":
|
|
return <ConfigurationVersionsSection projectId={projectId} onSettingsRefresh={() => refreshSettingsForm(false)} />;
|
|
case "agent-permissions":
|
|
return (
|
|
<AgentPermissionsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "memory":
|
|
return (
|
|
<MemorySection
|
|
form={form}
|
|
setForm={setForm}
|
|
memory={{
|
|
memoryCapabilities,
|
|
memoryBackendStatus,
|
|
memoryBackendLoading,
|
|
memoryBackendError,
|
|
memoryFiles,
|
|
selectedMemoryPath,
|
|
setSelectedMemoryPath,
|
|
memoryContent,
|
|
setMemoryContent,
|
|
memoryLoading,
|
|
memoryDirty,
|
|
setMemoryDirty,
|
|
memoryTestQuery,
|
|
setMemoryTestQuery,
|
|
memoryTestLoading,
|
|
memoryTestResult,
|
|
qmdInstallLoading,
|
|
dreamRunning,
|
|
memoryCompactLoading,
|
|
onInstallQmd: handleInstallQmd,
|
|
onTestMemoryRetrieval: handleTestMemoryRetrieval,
|
|
onDreamNow: handleDreamNow,
|
|
onCompactMemory: handleCompactMemory,
|
|
onSaveMemory: handleSaveMemory,
|
|
}}
|
|
/>
|
|
);
|
|
case "research-global":
|
|
return (
|
|
<ResearchGlobalSection
|
|
form={form}
|
|
setForm={setForm}
|
|
authProviders={authProviders}
|
|
onNavigateToSection={setActiveSection}
|
|
/>
|
|
);
|
|
case "research-project":
|
|
return (
|
|
<ResearchProjectSection
|
|
form={form}
|
|
setForm={setForm}
|
|
researchLimitError={researchLimitError}
|
|
/>
|
|
);
|
|
case "voice-input":
|
|
return <VoiceInputSection form={form} setForm={setForm} />;
|
|
case "cli-binary":
|
|
return <CliBinarySection />;
|
|
case "experimental":
|
|
return (
|
|
<ExperimentalSection
|
|
form={form}
|
|
setForm={setForm}
|
|
knownFeatures={KNOWN_EXPERIMENTAL_FEATURES}
|
|
legacyAliases={EXPERIMENTAL_FEATURE_LEGACY_ALIASES}
|
|
getCanonicalKey={getCanonicalExperimentalFeatureKey}
|
|
isFeatureEnabled={isDashboardExperimentalFeatureEnabled}
|
|
hiddenFeatureKeys={HIDDEN_EXPERIMENTAL_FEATURE_KEYS}
|
|
/>
|
|
);
|
|
case "backups-global":
|
|
return (
|
|
<DatabaseBackupsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
backupInfo={backupInfo}
|
|
backupLoading={backupLoading}
|
|
onBackupNow={handleBackupNow}
|
|
/>
|
|
);
|
|
case "backups":
|
|
return <BackupsSection form={form} setForm={setForm} />;
|
|
case "notifications":
|
|
return (
|
|
<NotificationsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
testNotificationLoading={testNotificationLoading}
|
|
testNotificationResult={testNotificationResult}
|
|
onTestProviderNotification={handleTestProviderNotification}
|
|
/>
|
|
);
|
|
case "node-sync":
|
|
return (
|
|
<NodeSyncSection
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "remote":
|
|
return (
|
|
<RemoteSection
|
|
form={form}
|
|
setForm={setForm}
|
|
remote={{
|
|
projectId,
|
|
addToast,
|
|
remoteStatus,
|
|
externalTunnel,
|
|
tunnelShareLink,
|
|
remoteBusyAction,
|
|
cloudflaredInstalling,
|
|
cloudflaredInstallError,
|
|
cloudflaredManualInstallCommand,
|
|
cloudflaredMacFallbackCommand,
|
|
handleInstallCloudflared,
|
|
runRemoteAction,
|
|
remoteShortLivedToken,
|
|
setRemoteShortLivedToken,
|
|
remoteAuthLinkTokenType,
|
|
setRemoteAuthLinkTokenType,
|
|
remoteUrlPreview,
|
|
setRemoteUrlPreview,
|
|
remoteQrSvg,
|
|
setRemoteQrSvg,
|
|
}}
|
|
/>
|
|
);
|
|
case "prompts":
|
|
return (
|
|
<PromptsSection
|
|
form={form}
|
|
setForm={setForm}
|
|
onOpenWorkflowSettings={onOpenWorkflowSettings}
|
|
/>
|
|
);
|
|
case "plugins":
|
|
return (
|
|
<PluginsSection
|
|
projectId={projectId}
|
|
addToast={addToast}
|
|
activePluginsSubsection={activePluginsSubsection}
|
|
setActivePluginsSubsection={setActivePluginsSubsection}
|
|
onPluginsChanged={refreshInstalledRuntimeSections}
|
|
/>
|
|
);
|
|
case "authentication":
|
|
return (
|
|
<AuthenticationSection
|
|
auth={{
|
|
projectId,
|
|
addToast,
|
|
authProviders,
|
|
authLoading,
|
|
authActionInProgress,
|
|
apiKeyInputs,
|
|
setApiKeyInputs,
|
|
apiKeyErrors,
|
|
opencodeApiKeyRefreshStatus,
|
|
deviceCodes,
|
|
loginInstructions,
|
|
manualCodeConfigs,
|
|
manualCodeInputs,
|
|
setManualCodeInputs,
|
|
manualCodeSubmitInProgress,
|
|
loadAuthStatus,
|
|
handleLogin,
|
|
handleLogout,
|
|
handleCancelLogin,
|
|
handleSaveApiKey,
|
|
handleClearApiKey,
|
|
handleSubmitManualCode,
|
|
onReopenOnboarding,
|
|
}}
|
|
/*
|
|
FNXC:ProviderAuth 2026-07-24-17:05:
|
|
Authentication is presentational like every other section — the shell keeps
|
|
ownership of persistence. It needs the form only for the Anthropic
|
|
credential-precedence row, which belongs beside the two Anthropic cards
|
|
rather than buried in a general settings list.
|
|
*/
|
|
form={form}
|
|
setForm={setForm}
|
|
/>
|
|
);
|
|
case "hermes-runtime":
|
|
return <HermesRuntimeSection />;
|
|
case "openclaw-runtime":
|
|
return <OpenClawRuntimeSection />;
|
|
case "paperclip-runtime":
|
|
return <PaperclipRuntimeSection />;
|
|
}
|
|
};
|
|
|
|
/*
|
|
FNXC:Settings 2026-06-22-00:00:
|
|
Embedded settings is a main-content destination, not a dialog. It drops the fixed `.modal-overlay` backdrop and the inner card chrome (modal-overlay/modal/settings-modal classes), and instead uses `settings-embedded right-dock-embedded-view` (host) + `settings-modal--embedded` (panel) to fill the pane flush like other embedded views (Planning, Command Center). The modal path stays byte-identical.
|
|
*/
|
|
/*
|
|
FNXC:ModalTouchGeometry 2026-07-26-19:40:
|
|
The shell MUST stay a plain render function, never a component declared inside this render. A nested component is a
|
|
new element type on every render, so React remounts the whole Settings subtree on each keystroke and text inputs lose
|
|
focus after one character. Keep the returned element types (div / FloatingWindow) stable by calling this directly.
|
|
*/
|
|
const renderModalShell = (children: ReactNode) => isEmbedded ? (
|
|
<div
|
|
className="settings-embedded right-dock-embedded-view"
|
|
data-testid="settings-view"
|
|
role="region"
|
|
aria-label={t("settings.title", "Settings")}
|
|
>
|
|
{children}
|
|
</div>
|
|
) : (
|
|
<FloatingWindow
|
|
windowKey="settings"
|
|
title={t("settings.title", "Settings")}
|
|
ariaLabel={`${t("settings.title", "Settings")} dialog`}
|
|
onClose={() => void requestClose()}
|
|
hideHeader
|
|
dragHandleSelector=".settings-modal > .modal-header"
|
|
className="floating-window--settings"
|
|
defaultSize={{ width: 1100, height: 720 }}
|
|
minSize={{ width: 520, height: 480 }}
|
|
persistGeometryKey="floating-window:settings"
|
|
suspendGeometryPersistenceOnMobile
|
|
suspendGeometryPersistenceOnShortViewport
|
|
closeOnOutsidePointerDown={overlayDismissEnabled}
|
|
>
|
|
{children}
|
|
</FloatingWindow>
|
|
);
|
|
|
|
return renderModalShell(
|
|
<>
|
|
<div
|
|
className={isEmbedded ? "modal modal-lg settings-modal settings-modal--embedded" : "modal modal-lg settings-modal"}
|
|
style={isEmbedded ? undefined : keyboardStyle}
|
|
>
|
|
<div className={isEmbedded ? "modal-header modal-header--embedded" : "modal-header"}>
|
|
{/* FNXC:Settings 2026-06-22-01:00: Embedded title gains a Settings icon (size 20, matching the sidebar nav and shared ViewHeader) so the embedded settings panel reads consistently with other main-content destinations; title is already 1.125rem. */}
|
|
<div className="settings-modal-heading">
|
|
<h3>
|
|
{isEmbedded && <SettingsIcon size={20} aria-hidden="true" />}
|
|
<span>{t("settings.title", "Settings")}</span>
|
|
</h3>
|
|
</div>
|
|
<div className="settings-header-actions">
|
|
<a
|
|
href="https://github.com/Runfusion/Fusion"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="settings-github-star-btn"
|
|
aria-label={t("settings.header.starFusion", "Star Fusion on GitHub")}
|
|
title={t("settings.header.starFusion", "Star Fusion on GitHub")}
|
|
onClick={markStarClicked}
|
|
data-clicked={starClicked ? "true" : "false"}
|
|
>
|
|
<span className="settings-github-star-btn__action">
|
|
<ProviderIcon provider="github" size="sm" />
|
|
<Star size={11} aria-hidden="true" />
|
|
{t("settings.header.star", "Star")}
|
|
</span>
|
|
{gitHubStarCount !== null && (
|
|
<span className="settings-github-star-btn__count" aria-label={`${gitHubStarCount.toLocaleString()} stars`}>
|
|
{gitHubStarCount >= 1000
|
|
? `${(gitHubStarCount / 1000).toFixed(1)}k`
|
|
: gitHubStarCount.toLocaleString()}
|
|
</span>
|
|
)}
|
|
</a>
|
|
<a
|
|
href="https://discord.gg/ksrfuy7WYR"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="btn btn-sm settings-header-discord-btn"
|
|
aria-label={t("settings.header.joinDiscord", "Join our Discord")}
|
|
title={t("settings.header.joinDiscord", "Join our Discord")}
|
|
>
|
|
<DiscordIcon size={13} />
|
|
{t("settings.header.discord", "Discord")}
|
|
</a>
|
|
</div>
|
|
{!isEmbedded && (
|
|
<button className="modal-close" onClick={() => void requestClose()} aria-label={t("actions.close", "Close")}>
|
|
×
|
|
</button>
|
|
)}
|
|
{/*
|
|
FNXC:Settings 2026-07-07-00:00:
|
|
Mobile embedded Settings (taskView === "settings", presentation="embedded") has no left sidebar to exit
|
|
through — only the bottom MobileNavBar — so the header needs an explicit close affordance calling the
|
|
existing onClose prop (wired to closeSettingsView: modalManager.closeSettings() + back to board + refresh
|
|
app settings). Desktop/tablet embedded still exit via the sidebar (no button here), and the standalone
|
|
modal presentation keeps its own `!isEmbedded` `modal-close` button above, untouched and byte-identical.
|
|
*/}
|
|
{isEmbedded && viewportMode === "mobile" && (
|
|
<button
|
|
className="modal-close settings-embedded-mobile-close"
|
|
onClick={() => void requestClose()}
|
|
aria-label={t("actions.close", "Close")}
|
|
>
|
|
×
|
|
</button>
|
|
)}
|
|
</div>
|
|
{loading ? (
|
|
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>
|
|
) : (
|
|
<div className="settings-layout">
|
|
<aside
|
|
className="settings-navigation"
|
|
aria-label={t("settings.search.navigationLabel", "Settings navigation")}
|
|
style={settingsNavigationStyle}
|
|
>
|
|
{showMobileSectionPicker && (
|
|
<div className="settings-mobile-section-picker">
|
|
{/**
|
|
* FNXC:Settings 2026-07-09-00:00:
|
|
* FN-7752 removes the visible mobile section-picker label to reclaim vertical space on narrow Settings screens. The select keeps "Settings Section" as its aria-label so screen readers and getByLabelText tests keep the same accessible name without an empty label shell.
|
|
*/}
|
|
<div className="settings-mobile-section-picker-control-row">
|
|
{hasSettingsSearchResults ? (
|
|
<select
|
|
id="settings-mobile-section"
|
|
aria-label={t("settings.mobileNav.label", "Settings Section")}
|
|
className="select touch-target"
|
|
value={activeSection}
|
|
onChange={(event) => void requestSectionChange(event.target.value as SectionId)}
|
|
>
|
|
{/*
|
|
FNXC:SettingsNavigation 2026-07-16-14:00:
|
|
FN-8236 makes the mobile picker mirror the desktop topic headers with native
|
|
optgroups. Sections retain SETTINGS_SECTIONS order within each group, which
|
|
keeps scoped Global entries immediately before their Project counterparts so
|
|
mobile operators can relate inherited settings without a separate sidebar.
|
|
*/}
|
|
{searchMatchedSections.map((groupHeader, groupIndex) => {
|
|
if (!groupHeader.isGroupHeader) return null;
|
|
|
|
const sections = searchMatchedSections.slice(groupIndex + 1).filter((section) => !section.isGroupHeader);
|
|
const nextGroupIndex = searchMatchedSections.slice(groupIndex + 1).findIndex((section) => section.isGroupHeader);
|
|
const groupSections = nextGroupIndex === -1 ? sections : sections.slice(0, nextGroupIndex);
|
|
|
|
if (groupSections.length === 0) return null;
|
|
|
|
return (
|
|
<optgroup key={groupHeader.id} label={t(groupHeader.labelKey, groupHeader.label)}>
|
|
{groupSections.map((section) => {
|
|
const label = t(section.labelKey, section.label);
|
|
return (
|
|
<option key={section.id} value={section.id}>
|
|
{resolveSettingsSectionOptionLabel(section, label)}
|
|
</option>
|
|
);
|
|
})}
|
|
</optgroup>
|
|
);
|
|
})}
|
|
</select>
|
|
) : (
|
|
<p className="settings-search-empty-hint">{t("settings.search.noMobileOptions", "No sections match this search.")}</p>
|
|
)}
|
|
{isMobileSettingsSearch && (
|
|
// FNXC:Settings 2026-07-09-12:00: mobile-only search toggle now lives inline beside the section picker control so section navigation and search read as one compact row; FN-7713 expand/hide behavior and active-query preservation remain unchanged.
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon settings-search-toggle"
|
|
onClick={() => setMobileSearchRowExpanded((expanded) => !expanded)}
|
|
aria-expanded={settingsSearchRowVisible}
|
|
aria-controls="settings-search-row-region"
|
|
aria-label={
|
|
settingsSearchRowVisible
|
|
? t("settings.search.toggleHide", "Hide search")
|
|
: t("settings.search.toggleShow", "Show search")
|
|
}
|
|
>
|
|
{settingsSearchRowVisible ? <SearchToggleCloseIcon size={16} /> : <Search size={16} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<label className="settings-advanced-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={showAdvancedSettings}
|
|
onChange={(event) => handleAdvancedSettingsChange(event.target.checked)}
|
|
/>
|
|
<span>{t("settings.advanced.toggle", "Advanced settings")}</span>
|
|
</label>
|
|
{settingsSearchRowVisible && (
|
|
<div className="settings-search" data-testid="settings-search">
|
|
<div id="settings-search-row-region" className="settings-search-row">
|
|
<label className="settings-search-label" htmlFor="settings-search-input">
|
|
{t("settings.search.label", "Search settings")}
|
|
</label>
|
|
<div className="settings-search-input-wrap">
|
|
<input
|
|
id="settings-search-input"
|
|
data-testid="settings-search-input"
|
|
className="input settings-search-input"
|
|
type="search"
|
|
value={settingsSearchQuery}
|
|
onChange={(event) => setSettingsSearchQuery(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && hasSettingsSearchQuery) {
|
|
event.stopPropagation();
|
|
setSettingsSearchQuery("");
|
|
}
|
|
}}
|
|
placeholder={t("settings.search.placeholder", "Search by setting or section")}
|
|
aria-describedby="settings-search-results"
|
|
/>
|
|
{hasSettingsSearchQuery && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm settings-search-clear"
|
|
onClick={() => setSettingsSearchQuery("")}
|
|
aria-label={t("settings.search.clear", "Clear settings search")}
|
|
>
|
|
{t("actions.clear", "Clear")}
|
|
</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
|
|
? 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>
|
|
)}
|
|
<nav className="settings-sidebar">
|
|
{hasSettingsSearchResults ? searchMatchedSections.map((section) => {
|
|
// Render group headers as non-clickable styled divs
|
|
if (section.isGroupHeader) {
|
|
return (
|
|
<div key={section.id} className="settings-group-header">
|
|
{t(section.labelKey, section.label)}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
key={section.id}
|
|
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
|
|
onClick={() => void requestSectionChange(section.id)}
|
|
title={
|
|
section.scope === "global"
|
|
? t("settings.nav.tooltip.global", "Shared across all projects")
|
|
: section.scope === "project"
|
|
? t("settings.nav.tooltip.project", "Specific to this project")
|
|
: undefined
|
|
}
|
|
>
|
|
{/*
|
|
FNXC:SettingsNavigation 2026-07-16-01:30:
|
|
A section's own icon wins over the generic scope glyph; the glyph is the fallback.
|
|
Previously `icon` only rendered when `scope` was undefined, so any scoped section was forced to the globe/folder pair and could not identify itself — Source Control looked exactly like MCP Servers and Scheduling in the nav.
|
|
Nothing is lost by yielding the glyph: these sections spell their tier out in the label ("Source Control · Global"), and the aria-label + tooltip below still announce it, so scope survives for assistive tech and hover.
|
|
*/}
|
|
{section.icon ? (
|
|
<section.icon
|
|
className="settings-scope-icon"
|
|
aria-label={
|
|
section.scope === "project"
|
|
? t("settings.nav.aria.project", "Project setting")
|
|
: t("settings.nav.aria.global", "Global setting")
|
|
}
|
|
size={16}
|
|
/>
|
|
) : (
|
|
<>
|
|
{section.scope === "global" && <Globe className="settings-scope-icon" aria-label={t("settings.nav.aria.global", "Global setting")} size={16} />}
|
|
{section.scope === "project" && <Folder className="settings-scope-icon" aria-label={t("settings.nav.aria.project", "Project setting")} size={16} />}
|
|
</>
|
|
)}
|
|
{t(section.labelKey, section.label)}
|
|
</button>
|
|
);
|
|
}) : (
|
|
<div className="settings-search-empty" role="status">
|
|
<p>{t("settings.search.noResults", "No settings sections match \"{{query}}\".", { query: settingsSearchQuery.trim() })}</p>
|
|
<button type="button" className="btn btn-sm" onClick={() => setSettingsSearchQuery("")}>{t("settings.search.clear", "Clear settings search")}</button>
|
|
</div>
|
|
)}
|
|
</nav>
|
|
</aside>
|
|
{settingsNavResizeEnabled && (
|
|
<div
|
|
className="settings-nav-resize-handle"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label={t("settings.nav.resize", "Resize settings navigation")}
|
|
aria-valuemin={SETTINGS_NAV_MIN_WIDTH}
|
|
aria-valuemax={SETTINGS_NAV_MAX_WIDTH}
|
|
aria-valuenow={settingsNavWidth}
|
|
tabIndex={0}
|
|
onPointerDown={handleSettingsNavResizePointerDown}
|
|
onKeyDown={handleSettingsNavResizeKeyDown}
|
|
/>
|
|
)}
|
|
<div
|
|
className="settings-content"
|
|
ref={settingsContentRef}
|
|
data-show-advanced={showAdvancedSettings ? "true" : "false"}
|
|
>
|
|
{/*
|
|
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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{viewportMode === "mobile" && updateCheckResultNode && (
|
|
<div className="settings-modal-footer-update-row">{updateCheckResultNode}</div>
|
|
)}
|
|
<div className="modal-actions">
|
|
<div className="settings-modal-footer-version">
|
|
<a
|
|
href="https://github.com/Runfusion/Fusion/discussions"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="btn btn-sm settings-footer-help-btn"
|
|
aria-label={t("settings.footer.helpDiscussions", "Help and discussions")}
|
|
title={t("settings.footer.helpDiscussions", "Help and discussions")}
|
|
>
|
|
{t("settings.footer.help", "Help")}
|
|
</a>
|
|
<div className="settings-update-check">
|
|
{appVersion && (
|
|
<button
|
|
type="button"
|
|
className="settings-version-check-btn"
|
|
onClick={() => {
|
|
void handleCheckForUpdates();
|
|
}}
|
|
disabled={updateCheckLoading}
|
|
aria-label={t("settings.footer.checkUpdates", "Check for updates")}
|
|
title={t("settings.footer.checkUpdates", "Check for updates")}
|
|
>
|
|
{/*
|
|
FNXC:Settings 2026-07-10-21:33:
|
|
Mobile Settings footer needs the compact v{{version}} label to preserve horizontal space; desktop and tablet keep the full Version {{version}} word.
|
|
*/}
|
|
<span className="settings-modal-version">
|
|
{viewportMode === "mobile"
|
|
? t("settings.footer.versionShort", "v{{version}}", { version: appVersion })
|
|
: t("settings.footer.version", "Version {{version}}", { version: appVersion })}
|
|
</span>
|
|
<RefreshCw size={12} className={updateCheckLoading ? "spinning" : undefined} />
|
|
</button>
|
|
)}
|
|
{viewportMode !== "mobile" && updateCheckResultNode}
|
|
</div>
|
|
</div>
|
|
<div className="modal-actions-left">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={handleExport}
|
|
title={t("settings.importExport.exportTitle", "Export settings to JSON file")}
|
|
>
|
|
{t("settings.importExport.exportBtn", "Export")}
|
|
</button>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
accept=".json,application/json"
|
|
style={{ display: "none" }}
|
|
onChange={handleFileSelect}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={importLoading}
|
|
title={t("settings.importExport.importTitleAttr", "Import settings from JSON file")}
|
|
>
|
|
{importLoading ? t("settings.importExport.loadingFile", "Loading…") : t("settings.importExport.importBtn", "Import")}
|
|
</button>
|
|
{/*
|
|
FNXC:SettingsReset 2026-07-04-00:35:
|
|
Reset Settings lives in the footer next to Import/Export in BOTH the modal and
|
|
embedded (SettingsView) presentations — the footer is not gated by isEmbedded
|
|
(only Cancel is), so this button renders in both automatically (FN-7506
|
|
Surface Enumeration: modal + embedded).
|
|
|
|
FNXC:SettingsReset 2026-08-02-21:45:
|
|
The mobile Settings footer needs the compact Reset label to preserve horizontal space alongside Help, version, Import, Export, and Close. Desktop and tablet keep the full Reset Settings wording while the existing destructive confirmation dialog remains unchanged.
|
|
*/}
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
data-testid="settings-reset"
|
|
onClick={() => setResetDialogOpen(true)}
|
|
disabled={loading}
|
|
title={t("settings.reset.buttonTitle", "Reset settings to their defaults")}
|
|
>
|
|
{viewportMode === "mobile"
|
|
? t("settings.reset.buttonShort", "Reset")
|
|
: t("settings.reset.button", "Reset Settings")}
|
|
</button>
|
|
</div>
|
|
{!isEmbedded && (
|
|
<div className="modal-actions-right">
|
|
<span className="settings-autosave-status" role="status">
|
|
{autoSaveStatus === "saving" ? t("settings.general.saving", "Saving…")
|
|
: autoSaveStatus === "saved" ? t("settings.general.saved", "Saved")
|
|
: autoSaveStatus === "error" ? t("settings.general.saveFailed", "Could not save") : null}
|
|
</span>
|
|
<button className="btn btn-sm" onClick={() => void requestClose()} disabled={loading}>
|
|
{t("settings.actions.close", "Close")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{overlapPathPickerIndex !== null && (
|
|
<div
|
|
className="modal-overlay open"
|
|
onClick={handleOverlapPathPickerOverlayClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("settings.scheduling.browseWorkspacePath", "Browse workspace path")}
|
|
>
|
|
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>{t("settings.scheduling.selectIgnoredOverlapPath", "Select ignored overlap path")}</h3>
|
|
<button className="modal-close" onClick={closeOverlapPathPicker} aria-label={t("actions.close", "Close")}>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="modal-body settings-overlap-path-picker-body">
|
|
<p className="settings-overlap-path-picker-note">
|
|
{t("settings.scheduling.overlapPickerNote", "Choose a file to ignore directly, or navigate into a folder and select the current directory.")}
|
|
</p>
|
|
<FileBrowser
|
|
entries={overlapPathPickerEntries}
|
|
currentPath={overlapPathPickerCurrentPath}
|
|
onSelectFile={selectOverlapIgnorePath}
|
|
onNavigate={setOverlapPathPickerPath}
|
|
loading={overlapPathPickerLoading}
|
|
error={overlapPathPickerError}
|
|
onRetry={refreshOverlapPathPicker}
|
|
workspace="project"
|
|
projectId={projectId}
|
|
/>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<div className="modal-actions-left">
|
|
<small>
|
|
{t("settings.fileBrowser.currentDirectory", "Current directory:")} <code>{overlapPathPickerCurrentPath === "." ? t("settings.fileBrowser.projectRoot", "(project root)") : overlapPathPickerCurrentPath}</code>
|
|
</small>
|
|
</div>
|
|
<div className="modal-actions-right">
|
|
<button className="btn btn-sm" onClick={closeOverlapPathPicker}>
|
|
{t("settings.actions.cancel", "Cancel")}
|
|
</button>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={handleSelectCurrentDirectoryForOverlapIgnore}
|
|
disabled={overlapPathPickerCurrentPath === "."}
|
|
>
|
|
{t("settings.scheduling.selectCurrentDir", "Select current directory")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{worktreesDirPickerOpen && (
|
|
<div
|
|
className="modal-overlay open"
|
|
onClick={handleWorktreesDirPickerOverlayClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("settings.worktrees.browseWorktreesDirectory", "Browse worktrees directory")}
|
|
>
|
|
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>{t("settings.worktrees.selectWorktreesDir", "Select worktrees directory")}</h3>
|
|
<button className="modal-close" onClick={closeWorktreesDirPicker} aria-label={t("actions.close", "Close")}>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="modal-body settings-overlap-path-picker-body">
|
|
<p className="settings-overlap-path-picker-note">
|
|
{t("settings.worktrees.worktreesPickerNote", "Navigate to the folder where Fusion should create task worktrees, then select the current directory.")}
|
|
</p>
|
|
<FileBrowser
|
|
entries={worktreesDirPickerEntries}
|
|
currentPath={worktreesDirPickerCurrentPath}
|
|
onSelectFile={selectWorktreesDirFromPicker}
|
|
onNavigate={setWorktreesDirPickerPath}
|
|
loading={worktreesDirPickerLoading}
|
|
error={worktreesDirPickerError}
|
|
onRetry={refreshWorktreesDirPicker}
|
|
workspace="project"
|
|
projectId={projectId}
|
|
/>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<div className="modal-actions-left">
|
|
<small>
|
|
{t("settings.fileBrowser.currentDirectory", "Current directory:")} <code>{worktreesDirPickerCurrentPath === "." ? t("settings.fileBrowser.projectRoot", "(project root)") : worktreesDirPickerCurrentPath}</code>
|
|
</small>
|
|
</div>
|
|
<div className="modal-actions-right">
|
|
<button className="btn btn-sm" onClick={closeWorktreesDirPicker}>
|
|
{t("settings.actions.cancel", "Cancel")}
|
|
</button>
|
|
<button className="btn btn-primary btn-sm" onClick={selectCurrentWorktreesDir}>
|
|
{t("settings.scheduling.selectCurrentDir", "Select current directory")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{worktreeCopyFilePickerIndex !== null && (
|
|
<div
|
|
className="modal-overlay open"
|
|
onClick={handleWorktreeCopyFilePickerOverlayClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("settings.worktrees.browseCopyFile", "Browse file to copy into new worktrees")}
|
|
>
|
|
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>{t("settings.worktrees.selectCopyFile", "Select file to copy")}</h3>
|
|
<button className="modal-close" onClick={closeWorktreeCopyFilePicker} aria-label={t("actions.close", "Close")}>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="modal-body settings-overlap-path-picker-body">
|
|
<p className="settings-overlap-path-picker-note">
|
|
{t("settings.worktrees.copyFilePickerNote", "Choose a repository file to copy into each newly assigned task worktree. Directories are not selected from this picker.")}
|
|
</p>
|
|
<FileBrowser
|
|
entries={worktreeCopyFilePickerEntries}
|
|
currentPath={worktreeCopyFilePickerCurrentPath}
|
|
onSelectFile={selectWorktreeCopyFile}
|
|
onNavigate={setWorktreeCopyFilePickerPath}
|
|
loading={worktreeCopyFilePickerLoading}
|
|
error={worktreeCopyFilePickerError}
|
|
onRetry={refreshWorktreeCopyFilePicker}
|
|
workspace="project"
|
|
projectId={projectId}
|
|
/>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<div className="modal-actions-left">
|
|
<small>
|
|
{t("settings.fileBrowser.currentDirectory", "Current directory:")} <code>{worktreeCopyFilePickerCurrentPath === "." ? t("settings.fileBrowser.projectRoot", "(project root)") : worktreeCopyFilePickerCurrentPath}</code>
|
|
</small>
|
|
</div>
|
|
<div className="modal-actions-right">
|
|
<button className="btn btn-sm" onClick={closeWorktreeCopyFilePicker}>
|
|
{t("settings.actions.cancel", "Cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import Confirmation Dialog */}
|
|
{importDialogOpen && importPreview && (
|
|
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setImportDialogOpen(false)} role="dialog" aria-modal="true">
|
|
<div className="modal modal-md">
|
|
<div className="modal-header">
|
|
<h3>{t("settings.importExport.importTitle", "Import Settings")}</h3>
|
|
<button className="modal-close" onClick={() => setImportDialogOpen(false)} aria-label={t("actions.close", "Close")}>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p>{t("settings.importExport.reviewPrompt", "Review the settings to be imported:")}</p>
|
|
|
|
{importPreview.global && Object.keys(importPreview.global).length > 0 && (
|
|
<div className="form-group">
|
|
<strong>{t("settings.importExport.globalSettings", "Global Settings:")}</strong>
|
|
<ul className="import-preview-list">
|
|
{Object.entries(importPreview.global)
|
|
.filter(([, v]) => v !== undefined)
|
|
.map(([key]) => (
|
|
<li key={key}>{key}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{importPreview.project && Object.keys(importPreview.project).length > 0 && (
|
|
<div className="form-group">
|
|
<strong>{t("settings.importExport.projectSettings", "Project Settings:")}</strong>
|
|
<ul className="import-preview-list">
|
|
{Object.entries(importPreview.project)
|
|
.filter(([, v]) => v !== undefined)
|
|
.map(([key]) => (
|
|
<li key={key}>{key}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="import-scope">{t("settings.importExport.importScope", "Import Scope:")}</label>
|
|
<select
|
|
id="import-scope"
|
|
value={importScope}
|
|
onChange={(e) => setImportScope(e.target.value as 'global' | 'project' | 'both')}
|
|
>
|
|
<option value="both">{t("settings.importExport.scopeBoth", "Both global and project settings")}</option>
|
|
<option value="global">{t("settings.importExport.scopeGlobal", "Global settings only")}</option>
|
|
<option value="project">{t("settings.importExport.scopeProject", "Project settings only")}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
{/* FNXC:SettingsHelp 2026-07-16-12:45: Inline help moved behind the shared "?" affordance — operator requirement: no inline description paragraphs in Settings. */}
|
|
<div className="settings-field-label-row">
|
|
<label htmlFor="import-merge" className="checkbox-label">
|
|
<input
|
|
id="import-merge"
|
|
type="checkbox"
|
|
checked={importMerge}
|
|
onChange={(e) => setImportMerge(e.target.checked)}
|
|
/>
|
|
{t("settings.importExport.mergeExisting", "Merge with existing settings (recommended)")}
|
|
</label>
|
|
<SettingsHelpTip settingKey="import-merge">{t("settings.importExport.replaceWarning", "If unchecked, existing settings will be replaced with imported values.")}</SettingsHelpTip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<button className="btn btn-sm" onClick={() => setImportDialogOpen(false)}>
|
|
{t("settings.actions.cancel", "Cancel")}
|
|
</button>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={handleImport}
|
|
disabled={importLoading}
|
|
>
|
|
{importLoading ? t("settings.importExport.importing", "Importing…") : t("settings.importExport.confirmImport", "Confirm Import")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/*
|
|
FNXC:SettingsReset 2026-07-04-00:40:
|
|
Reset Settings confirmation dialog (FN-7506). Mirrors the overlap-path-picker
|
|
modal-overlay dialog pattern in this file: role="dialog", aria-modal, aria-label,
|
|
overlay-click-to-cancel, and its own Escape handler (registered above). Offers two
|
|
destructive choices — reset the active section only (disabled/explained when the
|
|
section is excluded/non-key) and reset all project settings — plus Cancel.
|
|
*/}
|
|
{resetDialogOpen && (
|
|
<div
|
|
className="modal-overlay open"
|
|
onClick={handleResetDialogOverlayClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("settings.reset.dialogAriaLabel", "Reset settings")}
|
|
data-testid="settings-reset-dialog"
|
|
>
|
|
<div className="modal modal-md settings-reset-dialog" onClick={(event) => event.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>{t("settings.reset.dialogTitle", "Reset Settings")}</h3>
|
|
<button className="modal-close" onClick={closeResetDialog} aria-label={t("actions.close", "Close")}>
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p>{t("settings.reset.dialogBody", "Choose what to reset to its defaults. This cannot be undone.")}</p>
|
|
<div className="settings-reset-dialog__choice">
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger settings-reset-dialog__choice-btn"
|
|
data-testid="settings-reset-menu"
|
|
onClick={() => void handleResetActiveSection()}
|
|
disabled={resetInFlight || !activeSectionResetEntry}
|
|
title={activeSectionResetIneligibleReason}
|
|
>
|
|
{t("settings.reset.resetMenuAction", "Reset this menu ({{section}})", { section: activeSectionLabel })}
|
|
</button>
|
|
{activeSectionResetIneligibleReason && (
|
|
<small className="settings-reset-dialog__ineligible-reason">{activeSectionResetIneligibleReason}</small>
|
|
)}
|
|
</div>
|
|
<div className="settings-reset-dialog__choice">
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger settings-reset-dialog__choice-btn"
|
|
data-testid="settings-reset-all-project"
|
|
onClick={() => void handleResetAllProjectSettings()}
|
|
disabled={resetInFlight}
|
|
>
|
|
{t("settings.reset.resetAllProjectAction", "Reset all project settings")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<div className="modal-actions-right">
|
|
<button className="btn btn-sm" onClick={closeResetDialog} disabled={resetInFlight}>
|
|
{t("settings.actions.cancel", "Cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/*
|
|
FNXC:Settings 2026-06-22-00:00:
|
|
SettingsView is the embedded main-content presentation of SettingsModal. App.tsx lazy-imports this alias and renders it in renderMainContent() for taskView === "settings" with presentation defaulting to "embedded". It is a thin wrapper so the heavy SettingsModal body stays a single chunk and the modal path is unaffected.
|
|
*/
|
|
export function SettingsView(props: SettingsModalProps) {
|
|
return <SettingsModal presentation="embedded" {...props} />;
|
|
}
|