Files
fusion/packages/dashboard/app/components/SettingsModal.tsx
gsxdsm 0b299ee5fc FN-6201: move Authentication after General in settings
Move Authentication into the Global settings group after General.

- remove the standalone Account header from the settings sidebar
- place Authentication immediately after Global General in the Global group
- update the section inventory comments to match the new grouping, including runtime sections

Files changed:
 packages/dashboard/app/components/SettingsModal.tsx | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-6201

Fusion-Task-Lineage: 4cae66aa-43c5-4436-957b-4059915c2884
2026-06-10 12:10:02 -07:00

3150 lines
122 KiB
TypeScript

import { useState, useEffect, useCallback, useRef, type CSSProperties, type MouseEvent } from "react";
import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react";
import {
getErrorMessage,
normalizeMergeIntegrationWorktreeMode,
normalizeMergeAdvanceAutoSyncMode,
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
import { splitSettingsSave } from "./settings/save-split";
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 { ResearchGlobalSection } from "./settings/sections/ResearchGlobalSection";
import { RemoteSection } from "./settings/sections/RemoteSection";
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 } from "./settings/sections/ProjectModelsSection";
import { SchedulingSection } from "./settings/sections/SchedulingSection";
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 { AgentPermissionsSection } from "./settings/sections/AgentPermissionsSection";
import { MemorySection } from "./settings/sections/MemorySection";
import { ResearchProjectSection } from "./settings/sections/ResearchProjectSection";
import { BackupsSection } from "./settings/sections/BackupsSection";
import { PluginsSection } from "./settings/sections/PluginsSection";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
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 { useModalResizePersist } from "../hooks/useModalResizePersist";
import { ProviderIcon } from "./ProviderIcon";
import { generateUniquePresetId } from "../utils/modelPresets";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus";
import { type TrackingRepoOption } from "./TrackingRepoSelect";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
// ---------------------------------------------------------------------------
// GitHub star count — fetched once per session, cached in localStorage (1 h).
// ---------------------------------------------------------------------------
const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
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 useGitHubStarCount(): number | null {
const [count, setCount] = useState<number | null>(() => {
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
return parsed.count;
}
}
} catch {
// ignore malformed cache
}
return null;
});
useEffect(() => {
// If we already have a fresh count from the initial state, skip the fetch.
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
return;
}
}
} catch {
// ignore
}
fetch("https://api.github.com/repos/Runfusion/Fusion")
.then((res) => {
if (!res.ok) return;
return res.json() as Promise<{ stargazers_count?: number }>;
})
.then((data) => {
if (data && typeof data.stargazers_count === "number") {
const cache: StarCache = { count: data.stargazers_count, fetchedAt: Date.now() };
try {
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
} catch {
// quota exceeded — just skip
}
setCount(data.stargazers_count);
}
})
.catch(() => {
// Network failure — hide count gracefully, no update
});
}, []);
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 */
type SettingsSection = {
id: string;
label: string;
labelKey: string;
scope: "global" | "project" | undefined;
icon?: typeof Globe;
isGroupHeader?: boolean;
};
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group (shared across all Fusion projects)
{ id: "__global_header", label: "Global", labelKey: "settings.nav.globalHeader", scope: undefined, isGroupHeader: true },
{ id: "global-general", label: "General", labelKey: "settings.nav.globalGeneral", scope: "global" },
{ id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe },
{ id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global" },
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" },
{ id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" },
{ id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" },
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
{ id: "remote", label: "Remote Access & Node Sync", labelKey: "settings.nav.remote", scope: "global" },
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
// Runtimes group (plugin runtimes with their own settings)
{ id: "__runtimes_header", label: "Runtimes", labelKey: "settings.nav.runtimesHeader", scope: undefined, isGroupHeader: true },
{ id: "hermes-runtime", label: "Hermes", labelKey: "settings.nav.hermesRuntime", scope: "global" },
{ id: "openclaw-runtime", label: "OpenClaw", labelKey: "settings.nav.openclawRuntime", scope: "global" },
{ id: "paperclip-runtime", label: "Paperclip", labelKey: "settings.nav.paperclipRuntime", scope: "global" },
// Project group (specific to this project)
{ id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true },
{ id: "general", label: "Project General", labelKey: "settings.nav.projectGeneral", scope: "project" },
{ id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project" },
{ id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project" },
{ id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project" },
{ id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project" },
{ id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project" },
{ id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project" },
{ id: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project" },
{ id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project" },
{ id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project" },
{ id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" },
{ id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" },
{ id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" },
{ id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" },
{ id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project" },
];
/** 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",
roadmap: "Roadmaps",
memoryView: "Memory Editor",
remoteAccess: "Remote Access",
skillsView: "Skills View",
nodesView: "Nodes View",
devServerView: "Dev Server",
todoView: "Todo List",
researchView: "Research View",
evalsView: "Evals View",
goalsView: "Goals View",
sandbox: "Sandbox (command isolation)",
chatRooms: "Chat Rooms",
agentOnboarding: "Planning-style Agent Onboarding",
workflowGraphExecutor: "Workflow Graph Engine (run custom workflows)",
workflowInterpreterDualObserve: "Workflow Graph Engine — dual-observe parity (diagnostic)",
};
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;
}
return Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES).some(
([legacyKey, canonicalKey]) => canonicalKey === key && features[legacyKey] === true,
);
}
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;
}
type LegacySectionId = "pi-extensions";
export type SectionId = SettingsSection["id"] | LegacySectionId;
const DEFAULT_SETTINGS_SECTION: SectionId = "global-general";
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 };
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 General 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;
/** Called when dashboard font scale changes */
onDashboardFontScaleChange?: (scalePct: number) => 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;
}
/** 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,
themeMode = "dark",
colorTheme = "default",
onThemeModeChange,
onColorThemeChange,
dashboardFontScalePct = 100,
onDashboardFontScaleChange,
onReopenOnboarding,
onOpenApprovals,
onOpenWorkflowSettings,
}: SettingsModalProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const worktrunkInstall = useWorktrunkInstallStatus(projectId);
const worktrunkInstallVerified = worktrunkInstall.status === "installed";
const viewportMode = useViewportMode();
useMobileScrollLock(true);
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 modalRef = useRef<HTMLDivElement>(null);
const settingsContentRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
const sessionBannersHidden = useSessionBannersHidden();
const [form, setForm] = useState<SettingsFormState>({
maxConcurrent: 2,
maxTriageConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
heartbeatMultiplier: 1,
groupOverlappingFiles: true,
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
mergeIntegrationWorktree: "reuse-task-worktree",
mergeAdvanceAutoSync: "stash-and-ff",
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false },
recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktreesDir: "",
worktrunk: {
enabled: false,
binaryPath: "",
onFailure: "fail",
},
includeTaskIdInCommit: true,
worktreeInitCommand: "",
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyAccessToken: undefined,
failureNotificationMode: "sticky-only",
failureNotificationDelayMs: 30000,
webhookEnabled: false,
webhookUrl: undefined,
webhookFormat: "generic",
webhookEvents: undefined,
});
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 [isSaving, setIsSaving] = useState(false);
// 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);
// Track initial scoped values for null-as-delete semantics on project overrides
const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial<Settings> } | null>(null);
// Find the first non-group-header section for visibility fallback handling
const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader);
const [activeSection, setActiveSection] = useState<SectionId>(() => {
if (initialSection === "pi-extensions") {
return "plugins";
}
return initialSection ?? DEFAULT_SETTINGS_SECTION;
});
// 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",
);
const [showMobileSectionPicker, setShowMobileSectionPicker] = useState(() =>
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
: false,
);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | 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 {
entries: overlapPathPickerEntries,
currentPath: overlapPathPickerCurrentPath,
setPath: setOverlapPathPickerPath,
loading: overlapPathPickerLoading,
error: overlapPathPickerError,
refresh: refreshOverlapPathPicker,
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const {
entries: worktreesDirPickerEntries,
currentPath: worktreesDirPickerCurrentPath,
setPath: setWorktreesDirPickerPath,
loading: worktreesDirPickerLoading,
error: worktreesDirPickerError,
refresh: refreshWorktreesDirPicker,
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId);
const { nodes } = useNodes();
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView");
const visibleSections = SETTINGS_SECTIONS.filter((section) => {
if (section.id === "remote") {
return remoteAccessEnabled;
}
if (section.id === "research-global" || section.id === "research-project") {
return researchViewEnabled;
}
if (section.id === "scheduled-evals") {
return evalsViewEnabled;
}
return true;
});
const firstVisibleSectionId = visibleSections.some((section) => section.id === DEFAULT_SETTINGS_SECTION)
? DEFAULT_SETTINGS_SECTION
: (visibleSections.find((section) => !section.isGroupHeader)?.id ?? firstNonHeaderSection?.id ?? "general");
/** Get the scope of the currently active section */
const activeSectionScope = visibleSections.find((s) => s.id === activeSection)?.scope;
useEffect(() => {
if (activeSection === "remote" && !remoteAccessEnabled) {
setActiveSection(firstVisibleSectionId);
return;
}
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)) {
setActiveSection(firstVisibleSectionId);
}
}, [activeSection, remoteAccessEnabled, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, 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
const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState<number | undefined>(4);
const initialGlobalMaxConcurrentRef = useRef<number | undefined>(4);
const hasFetchedGlobalConcurrencyRef = useRef(false);
const globalConcurrencyDirtyRef = useRef(false);
// 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",
});
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(event ? event.matches : mediaQuery.matches);
};
updateMobilePicker();
mediaQuery.addEventListener("change", updateMobilePicker);
return () => mediaQuery.removeEventListener("change", updateMobilePicker);
}, []);
useEffect(() => {
// Load both merged and scoped settings to enable inheritance detection
Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)])
.then(([s, scoped]) => {
const normalizedSettings = {
...s,
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
};
setForm(normalizedSettings);
setInitialValues(normalizedSettings); // Store initial values to detect explicit clears
setScopedSettings(scoped);
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
setLoading(false);
})
.catch((err) => {
addToast(getErrorMessage(err), "error");
setLoading(false);
});
}, [addToast, projectId]);
useEffect(() => {
if (activeSection !== "scheduling" || hasFetchedGlobalConcurrencyRef.current) {
return;
}
let cancelled = false;
fetchGlobalConcurrency()
.then((state) => {
if (cancelled) {
return;
}
if (!globalConcurrencyDirtyRef.current) {
setGlobalMaxConcurrent(state.globalMaxConcurrent);
}
initialGlobalMaxConcurrentRef.current = state.globalMaxConcurrent;
hasFetchedGlobalConcurrencyRef.current = true;
})
.catch(() => {
// Silently fail — global concurrency may not be available
});
return () => {
cancelled = true;
};
}, [activeSection]);
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);
try {
const result = await checkForUpdates();
setUpdateCheckResult(result);
if (result.error) {
addToast(result.error, "error");
}
} catch (error) {
const message = getErrorMessage(error) || "Failed to check for updates";
setUpdateCheckResult({
currentVersion: appVersion ?? "unknown",
latestVersion: null,
updateAvailable: false,
error: message,
});
addToast(message, "error");
} finally {
setUpdateCheckLoading(false);
}
}, [addToast, appVersion]);
const renderUpdateCheckResultContent = useCallback(() => {
if (!updateCheckResult) {
return null;
}
if (updateCheckResult.error) {
return updateCheckResult.error;
}
if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) {
return (
<>
{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>
</>
);
}
return t("settings.general.upToDate", "You're up to date ✓");
}, [updateCheckResult]);
// 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;
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(() => {
if (activeSection !== "general") {
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(() => {
if (activeSection !== "global-general" || 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]);
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) {
window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank");
}
// 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 {
const config = providerId === "ntfy"
? {
ntfyEnabled: form.ntfyEnabled,
ntfyTopic: form.ntfyTopic,
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
}
: providerId === "ntfy-message"
? { messageEventType: "message:agent-to-user" }
: providerId === "ntfy-room"
? { 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 || "Failed to create backup", "error");
}
} catch (err) {
addToast(getErrorMessage(err) || "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" ? "global" : scope === "project" ? "project" : "all";
addToast(`Settings exported (${scopeLabel} scope)`, "success");
} catch (err) {
addToast(getErrorMessage(err) || "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(`Invalid JSON file: ${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(`${result.globalCount} global`);
if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
if (result.workflowSettingsCount > 0) parts.push(`${result.workflowSettingsCount} workflow setting value(s)`);
addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
setImportDialogOpen(false);
setImportPreview(null);
setImportFile(null);
// Refresh settings to show imported values
const refreshed = await fetchSettings(projectId);
setForm(refreshed);
} else {
addToast(result.error || "Import failed", "error");
}
} catch (err) {
addToast(getErrorMessage(err) || "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]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
const overlayDismissProps = useOverlayDismiss(onClose);
/**
* 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;
projectProviderKey: keyof Settings;
projectModelKey: keyof Settings;
helperText: string;
fallbackOrder: string;
}
/** All five 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",
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",
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",
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",
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: "summarization",
label: "Title and Git Commit Message Summarization Model",
globalProviderKey: "titleSummarizerGlobalProvider",
globalModelKey: "titleSummarizerGlobalModelId",
projectProviderKey: "titleSummarizerProvider",
projectModelKey: "titleSummarizerModelId",
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",
},
];
/**
* 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,
}));
}
const openOverlapPathPicker = useCallback((index: number) => {
setOverlapPathPickerIndex(index);
setOverlapPathPickerPath(".");
}, [setOverlapPathPickerPath]);
const closeOverlapPathPicker = useCallback(() => {
setOverlapPathPickerIndex(null);
}, []);
const selectOverlapIgnorePath = useCallback((path: string) => {
if (overlapPathPickerIndex === null) 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) => {
const normalizedPath = path.endsWith("/") ? path : `${path}/`;
setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
closeWorktreesDirPicker();
}, [closeWorktreesDirPicker]);
const selectCurrentWorktreesDir = useCallback(() => {
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 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, ""] };
});
}, []);
const handleSave = useCallback(async () => {
if (isSaving) return;
if (prefixError || presetDraft) return;
const limits = form.researchSettings?.limits;
if (limits?.maxConcurrentRuns !== undefined && (!Number.isFinite(limits.maxConcurrentRuns) || limits.maxConcurrentRuns < 1)) {
setResearchLimitError("Research max concurrent runs must be at least 1.");
return;
}
if (limits?.maxSourcesPerRun !== undefined && (!Number.isFinite(limits.maxSourcesPerRun) || limits.maxSourcesPerRun < 1)) {
setResearchLimitError("Research max sources per run must be at least 1.");
return;
}
if (limits?.maxDurationMs !== undefined && (!Number.isFinite(limits.maxDurationMs) || limits.maxDurationMs < 1000)) {
setResearchLimitError("Research max duration must be at least 1000 ms.");
return;
}
if (limits?.requestTimeoutMs !== undefined && (!Number.isFinite(limits.requestTimeoutMs) || limits.requestTimeoutMs < 1000)) {
setResearchLimitError("Research request timeout must be at least 1000 ms.");
return;
}
setResearchLimitError(null);
setIsSaving(true);
try {
const payload = {
...form,
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
worktreesDir: form.worktreesDir?.trim() || undefined,
worktrunk: {
enabled: worktrunkInstallVerified && form.worktrunk?.enabled === true,
binaryPath: form.worktrunk?.binaryPath?.trim() || undefined,
onFailure: form.worktrunk?.onFailure ?? "fail",
},
taskPrefix: form.taskPrefix?.trim() || undefined,
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
githubAuthToken: form.githubAuthToken?.trim() || undefined,
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
};
// 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,
initialScopedValues,
activeSection,
});
// 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(),
globalMaxConcurrent !== initialGlobalMaxConcurrentRef.current
? updateGlobalConcurrency({ globalMaxConcurrent: globalMaxConcurrent ?? 4 })
: Promise.resolve(),
]);
addToast(t("settings.general.settingsSaved", "Settings saved"), "success");
onClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setIsSaving(false);
}
}, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId, activeSection, isSaving, t]);
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 ? "qmd installed successfully" : "qmd install finished, but qmd is still unavailable",
result.qmdAvailable ? "success" : "warning",
);
} catch (err) {
addToast(getErrorMessage(err) || "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 ?? "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 : "Installation failed");
} finally {
setCloudflaredInstalling(false);
}
}, [addToast, projectId]);
/** Render a scope indicator banner for the current section with theme-aware Lucide icons */
const renderScopeBanner = () => {
if (activeSectionScope === "global") {
return (
<div className="settings-scope-banner settings-scope-global">
<span className="settings-scope-icon"><Globe size={14} /></span>
<span>{t("settings.scope.globalBanner", "These settings are shared across all your Fusion projects.")}</span>
</div>
);
}
if (activeSectionScope === "project") {
return (
<div className="settings-scope-banner settings-scope-project">
<span className="settings-scope-icon"><Folder size={14} /></span>
<span>{t("settings.scope.projectBanner", "These settings only affect this project.")}</span>
</div>
);
}
return null;
};
const renderSectionFields = () => {
switch (activeSection) {
case "cli-agents":
return (
<>
{renderScopeBanner()}
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
</>
);
case "general":
return (
<GeneralSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
projectId={projectId}
addToast={addToast}
prefixError={prefixError}
setPrefixError={setPrefixError}
projectTrackingRepoOptions={projectTrackingRepoOptions}
projectTrackingRepoLoading={projectTrackingRepoLoading}
projectTrackingRepoError={projectTrackingRepoError}
/>
);
case "global-general":
return (
<GlobalGeneralSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
globalTrackingRepoOptions={globalTrackingRepoOptions}
globalTrackingRepoLoading={globalTrackingRepoLoading}
globalTrackingRepoError={globalTrackingRepoError}
/>
);
case "global-models":
return (
<GlobalModelsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
availableModels={availableModels}
modelsLoading={modelsLoading}
globalModelLanes={MODEL_LANES.filter((lane) => lane.laneId !== "default")}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
/>
);
case "secrets":
return <SecretsSection scopeBanner={renderScopeBanner()} addToast={addToast} />;
case "project-models":
return (
<ProjectModelsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
projectId={projectId}
addToast={addToast}
onOpenWorkflowSettings={onOpenWorkflowSettings}
models={{
modelLanes: MODEL_LANES,
getLaneStatus,
getLaneValue,
updateLaneValue,
resetLaneValue,
availableModels,
modelsLoading,
favoriteProviders,
favoriteModels,
onToggleFavorite: handleToggleFavorite,
onToggleModelFavorite: handleToggleModelFavorite,
editingPresetId,
setEditingPresetId,
presetDraft,
setPresetDraft,
onSavePresetDraft: savePresetDraft,
confirmDelete: confirm,
}}
/>
);
case "appearance":
return (
<AppearanceSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
themeMode={themeMode}
colorTheme={colorTheme}
dashboardFontScalePct={dashboardFontScalePct}
onThemeModeChange={onThemeModeChange}
onColorThemeChange={onColorThemeChange}
onDashboardFontScaleChange={onDashboardFontScaleChange}
sessionBannersHidden={sessionBannersHidden}
setSessionBannersHidden={setSessionBannersHidden}
/>
);
case "scheduling":
return (
<SchedulingSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
globalMaxConcurrent={globalMaxConcurrent}
onGlobalMaxConcurrentChange={(value) => {
globalConcurrencyDirtyRef.current = true;
setGlobalMaxConcurrent(value);
}}
onOverlapIgnorePathChange={handleOverlapIgnorePathChange}
onOpenOverlapPathPicker={openOverlapPathPicker}
onRemoveOverlapIgnorePath={handleRemoveOverlapIgnorePath}
onAddOverlapIgnorePath={handleAddOverlapIgnorePath}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
);
case "scheduled-evals":
return (
<ScheduledEvalsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
/>
);
case "node-routing":
return (
<NodeRoutingSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
nodes={nodes}
/>
);
case "worktrees":
return (
<WorktreesSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
gitRemotes={gitRemotes}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={worktrunkInstallVerified}
onOpenWorktreesDirPicker={openWorktreesDirPicker}
onOpenApprovals={onOpenApprovals}
/>
);
case "commands":
return (
<CommandsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
/>
);
case "merge":
return (
<MergeSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
integrationBranchOptions={integrationBranchOptions}
integrationBranchCustomMode={integrationBranchCustomMode}
setIntegrationBranchCustomMode={setIntegrationBranchCustomMode}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
);
case "agent-permissions":
return (
<AgentPermissionsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
/>
);
case "memory":
return (
<MemorySection
scopeBanner={renderScopeBanner()}
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
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
authProviders={authProviders}
onNavigateToSection={setActiveSection}
/>
);
case "research-project":
return (
<ResearchProjectSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
researchLimitError={researchLimitError}
/>
);
case "experimental":
return (
<ExperimentalSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
knownFeatures={KNOWN_EXPERIMENTAL_FEATURES}
legacyAliases={EXPERIMENTAL_FEATURE_LEGACY_ALIASES}
getCanonicalKey={getCanonicalExperimentalFeatureKey}
isFeatureEnabled={isExperimentalFeatureEnabled}
/>
);
case "backups":
return (
<BackupsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
backupInfo={backupInfo}
backupLoading={backupLoading}
onBackupNow={handleBackupNow}
/>
);
case "notifications":
return (
<NotificationsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
testNotificationLoading={testNotificationLoading}
testNotificationResult={testNotificationResult}
onTestProviderNotification={handleTestProviderNotification}
/>
);
case "node-sync":
return (
<NodeSyncSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
/>
);
case "remote":
return (
<RemoteSection
scopeBanner={renderScopeBanner()}
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 scopeBanner={renderScopeBanner()} form={form} setForm={setForm} />;
case "plugins":
return (
<PluginsSection
scopeBanner={renderScopeBanner()}
projectId={projectId}
addToast={addToast}
activePluginsSubsection={activePluginsSubsection}
setActivePluginsSubsection={setActivePluginsSubsection}
/>
);
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,
}}
/>
);
case "hermes-runtime":
return <HermesRuntimeSection />;
case "openclaw-runtime":
return <OpenClawRuntimeSection />;
case "paperclip-runtime":
return <PaperclipRuntimeSection />;
}
};
return (
<div className="modal-overlay open settings-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal modal-lg settings-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<div className="settings-modal-heading">
<h3>{t("settings.title", "Settings")}</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="Star Fusion on GitHub"
title="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" />
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="Join our Discord"
title="Join our Discord"
>
<DiscordIcon size={13} />
{t("settings.header.discord", "Discord")}
</a>
</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
{loading ? (
<div className="settings-empty-state settings-loading">{t("settings.loading", "Loading…")}</div>
) : (
<div className="settings-layout">
{showMobileSectionPicker && (
<div className="settings-mobile-section-picker">
<label htmlFor="settings-mobile-section">{t("settings.mobileNav.label", "Settings Section")}</label>
<select
id="settings-mobile-section"
className="select touch-target"
value={activeSection}
onChange={(event) => setActiveSection(event.target.value as SectionId)}
>
{visibleSections.filter((section) => !section.isGroupHeader).map((section) => (
<option key={section.id} value={section.id}>
{t(section.labelKey, section.label)}
</option>
))}
</select>
</div>
)}
<nav className="settings-sidebar">
{visibleSections.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={() => setActiveSection(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
}
>
{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} />}
{section.icon && !section.scope && (
<section.icon className="settings-scope-icon" aria-label={t("settings.nav.aria.global", "Global setting")} size={16} />
)}
{t(section.labelKey, section.label)}
</button>
);
})}
</nav>
<div className="settings-content" ref={settingsContentRef}>
{renderSectionFields()}
</div>
</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="Help and discussions"
title="Help and discussions"
>
<HelpCircle size={13} aria-hidden="true" />
{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="Check for updates"
title="Check for updates"
>
<span className="settings-modal-version">{t("settings.footer.version", "Version {{version}}", { version: appVersion })}</span>
<RefreshCw size={12} className={updateCheckLoading ? "spinning" : undefined} />
</button>
)}
{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>
)}
</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="Import settings from JSON file"
>
{importLoading ? t("settings.importExport.loadingFile", "Loading…") : t("settings.importExport.importBtn", "Import")}
</button>
</div>
<div className="modal-actions-right">
<button className="btn btn-sm" onClick={onClose}>
{t("settings.actions.cancel", "Cancel")}
</button>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={loading || isSaving}>
{t("settings.actions.save", "Save")}
</button>
</div>
</div>
</div>
{overlapPathPickerIndex !== null && (
<div
className="modal-overlay open"
onClick={handleOverlapPathPickerOverlayClick}
role="dialog"
aria-modal="true"
aria-label="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="Close">
&times;
</button>
</div>
<div className="modal-body settings-overlap-path-picker-body">
<p className="settings-overlap-path-picker-note">
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>
Current directory: <code>{overlapPathPickerCurrentPath === "." ? "(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="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="Close">
&times;
</button>
</div>
<div className="modal-body settings-overlap-path-picker-body">
<p className="settings-overlap-path-picker-note">
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>
Current directory: <code>{worktreesDirPickerCurrentPath === "." ? "(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>
)}
{/* 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="Close">
&times;
</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>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>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">Import Scope:</label>
<select
id="import-scope"
value={importScope}
onChange={(e) => setImportScope(e.target.value as 'global' | 'project' | 'both')}
>
<option value="both">Both global and project settings</option>
<option value="global">Global settings only</option>
<option value="project">Project settings only</option>
</select>
</div>
<div className="form-group">
<label htmlFor="import-merge" className="checkbox-label">
<input
id="import-merge"
type="checkbox"
checked={importMerge}
onChange={(e) => setImportMerge(e.target.checked)}
/>
Merge with existing settings (recommended)
</label>
<small>If unchecked, existing settings will be replaced with imported values.</small>
</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>
)}
</div>
);
}