From 8c9702e90913b3c654dc670686955e93c557b06a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 00:15:15 -0700 Subject: [PATCH] refactor(dashboard): extract SettingsModal Account/Global/Runtimes sections into shared-primitive components with pure save-split helper --- .../app/__tests__/settings-save-split.test.ts | 171 ++ .../app/__tests__/settings-sections.test.tsx | 139 + .../app/components/SettingsModal.tsx | 2305 ++--------------- .../app/components/settings/save-split.ts | 120 + .../settings/sections/AppearanceSection.tsx | 82 + .../sections/AuthenticationSection.tsx | 428 +++ .../settings/sections/ExperimentalSection.tsx | 94 + .../sections/GlobalGeneralSection.tsx | 176 ++ .../settings/sections/GlobalModelsSection.tsx | 437 ++++ .../settings/sections/NodeSyncSection.tsx | 101 + .../sections/NotificationsSection.tsx | 408 +++ .../settings/sections/RemoteSection.tsx | 424 +++ .../sections/ResearchGlobalSection.tsx | 270 ++ .../settings/sections/RuntimesSections.tsx | 35 + .../components/settings/sections/context.ts | 51 + 15 files changed, 3090 insertions(+), 2151 deletions(-) create mode 100644 packages/dashboard/app/__tests__/settings-save-split.test.ts create mode 100644 packages/dashboard/app/__tests__/settings-sections.test.tsx create mode 100644 packages/dashboard/app/components/settings/save-split.ts create mode 100644 packages/dashboard/app/components/settings/sections/AppearanceSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/NotificationsSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/RemoteSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/ResearchGlobalSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/RuntimesSections.tsx create mode 100644 packages/dashboard/app/components/settings/sections/context.ts diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts new file mode 100644 index 0000000000..cbf04f6bbc --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -0,0 +1,171 @@ +/** + * Characterization of SettingsModal's save-split (U9 / KTD-10). + * + * Pins the regression-critical behavior the redesign must preserve byte-for-byte: + * - one global + one project edit in a single session produce the expected + * `updateGlobalSettings` / `updateSettings` patches with strict scope routing; + * - clearing a project override emits null-as-delete; + * - untouched inherited project values are NOT written (changed-only gate); + * - explicit clears of global keys emit null, plain undefined is dropped. + * + * The split logic was lifted out of the modal into the pure `splitSettingsSave` + * helper; this test exercises it against the real `@fusion/core` key predicates + * so it stays honest about which keys land in which scope. + */ +import { describe, it, expect } from "vitest"; +import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core"; +import { splitSettingsSave } from "../components/settings/save-split"; + +// Sanity-anchor the scope of the concrete keys this test relies on, so the +// assertions below remain meaningful if core's catalog ever shifts. +describe("scope anchors", () => { + it("language and ntfyTopic are global; maxConcurrent and integrationBranch are project", () => { + expect(isGlobalSettingsKey("language")).toBe(true); + expect(isGlobalSettingsKey("ntfyTopic")).toBe(true); + expect(isProjectSettingsKey("maxConcurrent")).toBe(true); + expect(isProjectSettingsKey("integrationBranch")).toBe(true); + }); +}); + +describe("splitSettingsSave", () => { + it("routes one global + one project edit into the right patches", () => { + const initialValues = { language: "en", maxConcurrent: 2 } as never; + const initialScopedValues = { + global: { language: "en" }, + project: { maxConcurrent: 2 }, + } as never; + + const payload: Record = { + language: "fr", // global edit + maxConcurrent: 5, // project edit + }; + + const { globalPatch, projectPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection: "global-general", + }); + + expect(globalPatch).toEqual({ language: "fr" }); + expect(projectPatch).toEqual({ maxConcurrent: 5 }); + }); + + it("does not write project values that match the initial project-scoped value (changed-only gate)", () => { + // The gate compares the payload value against the initial *project-scoped* + // value: a value equal to its initial override is not re-written. This is + // what prevents every save from re-persisting unchanged overrides. + const initialScopedValues = { + global: {}, + project: { maxConcurrent: 3, integrationBranch: "main" }, + } as never; + + const payload: Record = { + maxConcurrent: 3, // unchanged override → skip + integrationBranch: "main", // unchanged override → skip + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({}); + }); + + it("writes a project value that differs from the initial project-scoped value", () => { + const initialScopedValues = { + global: {}, + project: { maxConcurrent: 3 }, + } as never; + + const payload: Record = { + maxConcurrent: 7, // changed from the initial override + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ maxConcurrent: 7 }); + }); + + it("emits null-as-delete when a project override is cleared", () => { + const initialScopedValues = { + global: {}, + project: { integrationBranch: "release" }, + } as never; + + const payload: Record = { + integrationBranch: undefined, // user cleared the pinned branch + }; + + const { projectPatch } = splitSettingsSave({ + payload, + initialValues: null, + initialScopedValues, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ integrationBranch: null }); + }); + + it("emits null-as-delete for an explicit clear of a global key", () => { + const initialValues = { ntfyTopic: "alerts" } as never; + + const payload: Record = { + ntfyTopic: undefined, // cleared; initial was defined → null + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "notifications", + }); + + expect(globalPatch).toEqual({ ntfyTopic: null }); + }); + + it("drops plain-undefined global keys that were never set", () => { + const payload: Record = { + ntfyTopic: undefined, // never had a value → passed through as undefined + }; + + const { globalPatch } = splitSettingsSave({ + payload, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "notifications", + }); + + // undefined survives the object but is dropped by JSON.stringify on the wire; + // the patch must not coerce it to null when there was nothing to clear. + expect(globalPatch.ntfyTopic).toBeUndefined(); + }); + + it("routes githubTrackingDefaultRepo to global only on the global-general section", () => { + const payloadGlobal: Record = { githubTrackingDefaultRepo: "org/repo" }; + const onGlobal = splitSettingsSave({ + payload: payloadGlobal, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "global-general", + }); + expect(onGlobal.globalPatch).toMatchObject({ githubTrackingDefaultRepo: "org/repo" }); + expect("githubTrackingDefaultRepo" in onGlobal.projectPatch).toBe(false); + + const onProject = splitSettingsSave({ + payload: { githubTrackingDefaultRepo: "org/repo" }, + initialValues: {} as never, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "general", + }); + expect("githubTrackingDefaultRepo" in onProject.globalPatch).toBe(false); + }); +}); diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx new file mode 100644 index 0000000000..d6360780a0 --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +/** + * Per-section smoke tests for the extracted SettingsModal sections (U9 / KTD-10). + * + * These pin the section-component contract: each section reads from `form` and + * emits edits via `setForm` (the shell keeps persistence/save-split). We cover + * three representative sections — an Appearance toggle round-trip, a + * Notifications field, and an Experimental flag — following the dashboard + * component-test conventions in settings-primitives.test.tsx. + */ +import { useState } from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; + +import { AppearanceSection } from "../components/settings/sections/AppearanceSection"; +import { NotificationsSection } from "../components/settings/sections/NotificationsSection"; +import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection"; +import type { SettingsFormState } from "../components/settings/sections/context"; + +expect.extend(jestDomMatchers); +afterEach(() => cleanup()); + +const emptyForm = {} as SettingsFormState; + +describe("AppearanceSection", () => { + function AppearanceHost() { + const [hidden, setHidden] = useState(false); + return ( + + ); + } + + it("round-trips the session-banner toggle through its setter", () => { + render(); + const toggle = screen.getByText("Hide AI session notification banners") + .closest("label")! + .querySelector("input[type=checkbox]") as HTMLInputElement; + expect(toggle.checked).toBe(false); + fireEvent.click(toggle); + expect(toggle.checked).toBe(true); + fireEvent.click(toggle); + expect(toggle.checked).toBe(false); + }); +}); + +describe("NotificationsSection", () => { + it("emits the chosen failure-notification mode via setForm", () => { + const setForm = vi.fn(); + render( + , + ); + const select = screen.getByLabelText("Failure notification mode") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "all" } }); + expect(setForm).toHaveBeenCalledTimes(1); + const updater = setForm.mock.calls[0][0] as (f: SettingsFormState) => SettingsFormState; + expect(updater(emptyForm)).toMatchObject({ failureNotificationMode: "all" }); + }); + + it("shows the ntfy topic field only when ntfy is enabled", () => { + const { rerender } = render( + , + ); + expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument(); + }); +}); + +describe("ExperimentalSection", () => { + const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" }; + const legacyAliases: Record = { devServer: "devServerView" }; + const getCanonicalKey = (k: string) => legacyAliases[k] ?? k; + const isFeatureEnabled = (features: Record, key: string) => features[key] === true; + + // Stateful host so the controlled checkbox actually toggles between renders + // (a bare mock setForm never re-renders, so jsdom reports the bound value). + function ExperimentalHost() { + const [form, setFormState] = useState( + { experimentalFeatures: {} } as SettingsFormState, + ); + return ( + + ); + } + + it("renders a row per known flag and round-trips the canonical key", () => { + render(); + expect(screen.getByText("Insights")).toBeInTheDocument(); + expect(screen.getByText("Roadmaps")).toBeInTheDocument(); + + const insightsToggle = document.getElementById("experimental-insights") as HTMLInputElement; + expect(insightsToggle.checked).toBe(false); + fireEvent.click(insightsToggle); + expect(insightsToggle.checked).toBe(true); + fireEvent.click(insightsToggle); + expect(insightsToggle.checked).toBe(false); + }); +}); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index a2d865997c..a1111c95d7 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1,28 +1,37 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type CSSProperties, type MouseEvent } from "react"; -import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2, CheckCircle, AlertTriangle } from "lucide-react"; +import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react"; import { AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, - THINKING_LEVELS, getErrorMessage, - isGlobalSettingsKey, - isProjectSettingsKey, resolvePlanningSettingsModel, - resolvePersistAgentThinkingLog, resolveProjectDefaultModel, resolveTitleSummarizerSettingsModel, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, } from "@fusion/core"; -import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } 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, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; -import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api"; +import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, AgentPromptsConfig } 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 { ProjectDefaultWorkflowField } from "./WorkflowSelector"; import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { useTranslation } from "react-i18next"; -import { ThemeSelector } from "./ThemeSelector"; -import { LanguageSelector } from "./LanguageSelector"; import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref"; import "./SettingsModal.css"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -32,19 +41,9 @@ import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager }))); const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager }))); -import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard"; -import { CursorCliProviderCard } from "./CursorCliProviderCard"; -import { CliBinaryPanel } from "./CliBinaryPanel"; -import { LlamaCppProviderCard } from "./LlamaCppProviderCard"; -import { HermesRuntimeCard } from "./HermesRuntimeCard"; -import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard"; -import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard"; import { PluginSlot } from "./PluginSlot"; import { AgentPromptsManager } from "./AgentPromptsManager"; -import { LoginInstructions } from "./LoginInstructions"; -import { OAuthManualCodeForm } from "./OAuthManualCodeForm"; import { ProviderIcon } from "./ProviderIcon"; -import { CustomProvidersSection } from "./CustomProvidersSection"; import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor"; import { AgentProvisioningPolicyEditor } from "./AgentProvisioningPolicyEditor"; import { SecretsView } from "./SecretsView"; @@ -243,14 +242,6 @@ function formatMemoryFileOptionLabel(file: MemoryFileInfo): string { return truncateMiddle(fullLabel, MEMORY_FILE_OPTION_LABEL_MAX_CHARS); } -function toCommaSeparatedInput(values?: string[]): string { - return values?.join(", ") ?? ""; -} - -function fromCommaSeparatedInput(value: string): string[] { - return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); -} - const SETTINGS_SECTIONS: SettingsSection[] = [ // Account group (scope-less items — independent of settings storage) { id: "__account_header", label: "Account", labelKey: "settings.nav.accountHeader", scope: undefined, isGroupHeader: true }, @@ -294,39 +285,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ const MS_PER_DAY = 24 * 60 * 60 * 1000; const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2; -const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [ - "in-review", - "merged", - "failed", - "awaiting-approval", - "awaiting-user-review", - "planning-awaiting-input", - "gridlock", - "fallback-used", - "memory-dreams-processed", - "message:agent-to-user", - "message:agent-to-agent", - "message:room", - "oauth-token-expired", -]; - -const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [ - { event: "in-review", label: "Task completed (in-review)", description: "When a task moves to In Review (ready for review)" }, - { event: "merged", label: "Task merged", description: "When a task is successfully merged to main" }, - { event: "failed", label: "Task failed", description: "When a task fails during execution (high priority)" }, - { event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" }, - { event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" }, - { event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" }, - { event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" }, - { event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" }, - { event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" }, - { event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" }, - { event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" }, - { event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" }, - { event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" }, - { event: "oauth-token-expired", label: "OAuth token expired", description: "Notify when a provider OAuth token (Codex, Claude, etc.) expires." }, -]; - /** 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. @@ -2022,90 +1980,17 @@ export function SettingsModal({ experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures), }; - // Always save both global and project settings with strict scope separation. - // - // SCOPE RULES: - // - Global lane keys (executionGlobalProvider, planningGlobalProvider, etc.) - // go to updateGlobalSettings - // - Project override lane keys (executionProvider, planningProvider, etc.) - // go to updateSettings ONLY when explicitly changed from initial state - // - Inherited project lanes (unset in project scope) are NOT written to project payload - // - Resetting a project lane sends null to delete it from project scope - - const globalPatch: Partial = {}; - for (const [key, value] of Object.entries(payload)) { - if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { - continue; - } - if (key === "persistAgentThinkingLog") { - continue; - } - if (isGlobalSettingsKey(key)) { - // Implement null-as-delete semantics for global settings: - // - undefined values are dropped during JSON serialization - // - To explicitly clear a field, send null instead - // - We detect explicit clears by comparing with initial values: - // if current value is undefined AND initial was defined, use null - const initialValue = initialValues?.[key as keyof GlobalSettings]; - if (value === undefined && initialValue !== undefined) { - (globalPatch as Record)[key] = null; // null means "explicitly clear" - } else { - (globalPatch as Record)[key] = value; - } - } - } - - // Project settings: Only include keys that were explicitly changed. - // This prevents inherited effective values from being persisted as explicit overrides. - const projectPatch: Partial = {}; - for (const [key, value] of Object.entries(payload)) { - if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only fields - if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue; - if (!isProjectSettingsKey(key)) continue; - - // Get the initial project-scoped value (null if not set) - const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings]; - - // Check if this value is a model lane key that tracks inheritance - const isModelLaneKey = [ - "planningProvider", "planningModelId", - "validatorProvider", "validatorModelId", - "executionProvider", "executionModelId", - "titleSummarizerProvider", "titleSummarizerModelId", - "defaultProviderOverride", "defaultModelIdOverride", - "planningFallbackProvider", "planningFallbackModelId", - "validatorFallbackProvider", "validatorFallbackModelId", - "titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", - ].includes(key); - - if (isModelLaneKey) { - // For model lanes: only write if explicitly changed from initial project state - if (value !== initialProjectValue) { - // Detect explicit reset: current is undefined/null but initial was set - if ((value === undefined || value === null) && initialProjectValue !== undefined && initialProjectValue !== null) { - (projectPatch as Record)[key] = null; // null-as-delete - } else if (value !== undefined) { - (projectPatch as Record)[key] = value; - } - } - } else { - // For non-model settings: only write keys the user actually - // changed, matching the model-lane gate above. Without this, - // every effective/inherited value in `payload` would be - // serialized as an explicit project override, silently breaking - // inheritance for every project setting on every save. - // Within the changed-set, apply null-as-delete so an explicit - // clear (e.g. unpinning `integrationBranch` back to auto-detect) - // survives `JSON.stringify` instead of being silently dropped. - if (value !== initialProjectValue) { - if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) { - (projectPatch as Record)[key] = null; - } else if (value !== undefined) { - (projectPatch as Record)[key] = value; - } - } - } - } + // 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 @@ -2627,548 +2512,30 @@ export function SettingsModal({ ); case "global-general": return ( - <> - {renderScopeBanner()} -

General

-
- - - setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) - } - /> - Projects inherit this value when they do not set a project default tracking repo. -
- -
- - - When disabled, tool rows are still logged but detailed tool payloads are omitted. - Very large tool payloads may still be clipped even when this stays enabled. - -
-
-
Save AI thinking logs
- - - - Leave both thinking toggles off to keep the original default behavior. - This only controls persisted thinking rows and does not affect assistant text or tool rows. - -
-
- - - When enabled, the dashboard probes for a globally-installed{" "} - fn / fusion CLI by spawning{" "} - <bin> --version. Disable this if your local - dev process is the source of truth and you don't want any - outdated globally-installed binary executed during the probe. - -
-

Updates

-
- - - When enabled, Fusion checks npm for new versions of{" "} - @runfusion/fusion and shows update notices in the CLI and dashboard. - Cadence is governed by the frequency below. - -
-
- - - - Controls how often the dashboard re-fetches the npm registry. - Use the version + refresh control in the header to trigger an - immediate check at any time. - -
-
- - - When enabled (default), the dashboard automatically reloads when it - detects a new build version — either from server rebuilds or service - worker updates. Disable this to stay on the current version until you - manually refresh. - -
- + ); - case "global-models": { - const selectedValue = form.defaultProvider && form.defaultModelId - ? `${form.defaultProvider}/${form.defaultModelId}` - : ""; - const globalModelLanes = MODEL_LANES.filter( - (lane) => lane.laneId !== "default", - ); - + case "global-models": return ( - <> - {renderScopeBanner()} - - {/* --- Default Model --- */} -

Default Model

- {modelsLoading ? ( -
{t("settings.models.loadingModels", "Loading available models…")}
- ) : availableModels.length === 0 ? ( -
- {t("settings.models.noModels", "No models available. Configure authentication first.")} -
- ) : ( - <> -
- - { - if (!val) { - setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - defaultProvider: val.slice(0, slashIdx), - defaultModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. -
- -
- - { - if (!val) { - setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined })); - } else { - const slashIdx = val.indexOf("/"); - setForm((f) => ({ - ...f, - fallbackProvider: val.slice(0, slashIdx), - fallbackModelId: val.slice(slashIdx + 1), - })); - } - }} - placeholder="No fallback" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. -
- - )} - {(() => { - const selectedModel = availableModels.find( - (m) => m.provider === form.defaultProvider && m.id === form.defaultModelId, - ); - if (selectedModel && !selectedModel.reasoning) return null; - return ( -
- - - Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. -
- ); - })()} - - {availableModels.length > 0 && ( - <> -

Model Lanes

-

- Global baseline models for each AI role. Project settings can override these per-project. -

- {globalModelLanes.map((lane) => { - const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined; - const model = form[lane.globalModelKey as keyof Settings] as string | undefined; - const value = provider && model ? `${provider}/${model}` : ""; - - return ( -
- - { - if (!selected) { - setForm((f) => ({ - ...f, - [lane.globalProviderKey]: undefined, - [lane.globalModelKey]: undefined, - })); - return; - } - - const slashIdx = selected.indexOf("/"); - setForm((f) => ({ - ...f, - [lane.globalProviderKey]: selected.slice(0, slashIdx), - [lane.globalModelKey]: selected.slice(slashIdx + 1), - })); - }} - placeholder="Use default" - favoriteProviders={favoriteProviders} - onToggleFavorite={handleToggleFavorite} - favoriteModels={favoriteModels} - onToggleModelFavorite={handleToggleModelFavorite} - /> - {lane.helperText} -
- ); - })} - - )} - - {/* --- Startup Model Sync --- */} -

Startup Model Sync

-
- - - When enabled, startup fetches the latest available models from the OpenRouter API so - model pickers always include the newest catalog. - -
-
- - - When enabled, startup refreshes models through the local opencode models opencode --refresh - flow and publishes them under the opencode-go provider in model pickers. - -
-
- OpenRouter advanced -
- - setForm((f) => ({ - ...f, - openrouterAppAttribution: { - ...(f.openrouterAppAttribution || {}), - referer: e.target.value, - }, - }))} - /> - Leave empty to omit this header. Default: https://runfusion.ai. -
-
- - setForm((f) => ({ - ...f, - openrouterAppAttribution: { - ...(f.openrouterAppAttribution || {}), - title: e.target.value, - }, - }))} - /> - Leave empty to omit this header. Default: Fusion. -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterModelFilters: { - ...(f.openrouterModelFilters || {}), - supported_parameters: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> - Comma-separated values sent to OpenRouter model sync. -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterModelFilters: { - ...(f.openrouterModelFilters || {}), - output_modalities: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> - Comma-separated values sent to OpenRouter model sync. -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - order: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - ignore: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> -
-
- - { - const parsed = fromCommaSeparatedInput(e.target.value); - setForm((f) => ({ - ...f, - openrouterProviderPreferences: { - ...(f.openrouterProviderPreferences || {}), - only: parsed.length > 0 ? parsed : undefined, - }, - })); - }} - /> -
-
- - -
-
- - -
-
- -
-
- - + lane.laneId !== "default")} + favoriteProviders={favoriteProviders} + favoriteModels={favoriteModels} + onToggleFavorite={handleToggleFavorite} + onToggleModelFavorite={handleToggleModelFavorite} + /> ); - } case "secrets": return ( @@ -3722,41 +3089,19 @@ export function SettingsModal({ case "appearance": return ( - <> - {renderScopeBanner()} -

{t("settings.appearance.title", "Appearance")}

- { - setForm((f) => ({ ...f, themeMode: mode })); - onThemeModeChange?.(mode); - }} - onColorThemeChange={(theme) => { - setForm((f) => ({ ...f, colorTheme: theme })); - onColorThemeChange?.(theme); - }} - onDashboardFontScaleChange={(scalePct) => { - setForm((f) => ({ ...f, dashboardFontScalePct: scalePct })); - onDashboardFontScaleChange?.(scalePct); - }} - /> - -
- - - Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. - -
- + ); case "scheduling": return ( @@ -5601,245 +4946,16 @@ export function SettingsModal({ ); } - case "research-global": { - const resolvedProvider = - form.researchGlobalWebSearchProvider ?? - form.researchGlobalDefaults?.searchProvider ?? - "builtin"; - const externalProvider = - resolvedProvider === "searxng" || - resolvedProvider === "brave" || - resolvedProvider === "google" || - resolvedProvider === "tavily"; - const selectedCredentialProvider = - resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null; - const hasMissingResearchCredential = selectedCredentialProvider - ? authProviders.some((provider) => provider.id === selectedCredentialProvider && !provider.authenticated) - : false; - - const setSearchProvider = (provider: Settings["researchGlobalWebSearchProvider"]) => { - setForm((current) => ({ - ...current, - researchGlobalWebSearchProvider: provider, - researchGlobalDefaults: { - ...(current.researchGlobalDefaults ?? {}), - searchProvider: provider, - }, - })); - }; - + case "research-global": return ( - <> - {renderScopeBanner()} -

Research Defaults

-
- - - Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. - -
- Advanced — external search providers -
-
- - -
-
- - - setForm((current) => ({ - ...current, - researchGlobalSearxngUrl: event.target.value || undefined, - })) - } - placeholder="https://searx.example.com" - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalGoogleSearchCx: event.target.value || undefined, - })) - } - placeholder="custom-search-engine-id" - /> -
-
- Configure Brave, Tavily, and Google API keys in Authentication. - -
-
-
-
-
-
-
- - - setForm((current) => ({ - ...current, - researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), - researchGlobalDefaults: { - ...(current.researchGlobalDefaults ?? {}), - maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), - }, - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
- - - setForm((current) => ({ - ...current, - researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value), - })) - } - /> -
-
-
-
- - -
- - -
-
- {hasMissingResearchCredential && ( -
- Missing credentials for the selected research provider. - -
- )} - + ); - } case "research-project": { const limits = form.researchSettings?.limits; const sources = form.researchSettings?.enabledSources; @@ -6004,67 +5120,18 @@ export function SettingsModal({ ); } - case "experimental": { - const experimentalFeatures = form.experimentalFeatures ?? {}; - // Merge known features (always shown) with custom features from settings, - // while canonicalizing legacy aliases (e.g. devServer → devServerView) - // so only one user-visible row is rendered per feature. - const allFeatureKeys = Array.from( - new Set([ - ...Object.keys(KNOWN_EXPERIMENTAL_FEATURES), - ...Object.keys(experimentalFeatures).map(getCanonicalExperimentalFeatureKey), - ]) - ).sort((a, b) => a.localeCompare(b)); - const featureFlags = allFeatureKeys.map((key) => [key, isExperimentalFeatureEnabled(experimentalFeatures, key)] as const); - + case "experimental": return ( - <> - {renderScopeBanner()} -

Experimental Features

-
- - Experimental features are early capabilities that are not yet fully stable. - Enable them to test new functionality, but be aware they may change or be removed. - -
- -
- -
- {featureFlags.map(([key, enabled]) => ( - - ))} -
-
- + ); - } case "backups": return ( <> @@ -6271,785 +5338,53 @@ export function SettingsModal({ ); case "notifications": return ( - <> - {renderScopeBanner()} -

Notifications

- -
-
- - - Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts. -
-
- - { - const parsed = Number(e.target.value); - setForm((f) => ({ - ...f, - failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0, - })); - }} - /> - - How long a failure must persist before a push notification is sent. 0 = notify immediately. - -
-
- -
-
- ntfy - -
- {form.ntfyEnabled && ( -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, ntfyTopic: val || undefined })); - }} - /> - - Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "} - - Learn more about ntfy.sh - - - {form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && ( - - Topic must be 1–64 alphanumeric, hyphen, or underscore characters - - )} -
- Advanced -
- - { - const value = e.target.value; - setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined })); - }} - /> - - Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. - - - { - const value = e.target.value; - setForm((f) => ({ ...f, ntfyAccessToken: value || undefined })); - }} - /> - - Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. - -
-
-
-
- -
- {NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { - const checked = form.ntfyEvents?.includes(event) ?? true; - return ( -
- - {description} -
- ); - })} -
-
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined })); - }} - /> - - Base URL for deep links in notifications. When set, clicking a notification - opens the dashboard directly to the task. - - {form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && ( - - Must be a valid URL starting with http:// or https:// - - )} -
-
- - - -
- {(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && ( -
- {testNotificationResult["ntfy"] && ( - - General: {testNotificationResult["ntfy"].message} - - )} - {testNotificationResult["ntfy-message"] && ( - - Message inbox: {testNotificationResult["ntfy-message"].message} - - )} - {testNotificationResult["ntfy-room"] && ( - - Room reply: {testNotificationResult["ntfy-room"].message} - - )} -
- )} -
- )} -
- -
-
- Webhook - -
- {form.webhookEnabled && ( -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, webhookUrl: val || undefined })); - }} - /> -
-
- - -
-
- -
- {NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { - const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS]; - const checked = currentEvents.includes(event); - return ( -
- - {description} -
- ); - })} -
-
-
- -
- {testNotificationResult["webhook"] && ( -
- - {testNotificationResult["webhook"].message} - -
- )} -
- )} -
- + ); case "node-sync": return ( - <> - {renderScopeBanner()} -

Node Sync

-
- - Automatically synchronize settings between this node and connected remote nodes -
- {form.settingsSyncEnabled && ( - <> -
- - Include API keys and OAuth tokens in sync operations -
-
- - -
-
- - -
- - )} - {/* KTD-8: workflow settings are not yet part of the cross-node sync - channel. Non-dismissible, informational only, no action affordance. */} -

- {t( - "settings.nodeSync.workflowSettingsNotSynced", - "Workflow settings are not synced across nodes yet.", - )} -

- + ); - case "remote": { - const remoteForm = form as Record; - const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null; - const tunnelState = (remoteStatus?.state as RemoteStatus["state"] | "error" | undefined) ?? "stopped"; - const statusColor = tunnelState === "running" - ? "running" - : tunnelState === "starting" - ? "starting" - : tunnelState === "failed" || tunnelState === "error" - ? "error" - : "stopped"; - + case "remote": return ( - <> - {renderScopeBanner()} -

Remote Access

-
- - {tunnelState} - {remoteStatus?.provider && · {remoteStatus.provider}} - {remoteStatus?.url && {remoteStatus.url}} - {remoteStatus?.lastError && {remoteStatus.lastError}} -
- {tunnelState === "stopped" && externalTunnel && ( -
-
-
- {externalTunnel.url && {externalTunnel.url}} - {tunnelShareLink?.qrSvg && ( -
- Scan to open: - External tunnel QR code -
- )} -
- )} - {tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => { - let accessCode: string | null = null; - let tailnetUrl: string | null = remoteStatus?.url ?? null; - if (tunnelShareLink?.url) { - try { - const parsed = new URL(tunnelShareLink.url); - accessCode = parsed.searchParams.get("rt"); - if (!tailnetUrl) tailnetUrl = `${parsed.origin}/`; - } catch { - // fall through - } - } - return ( -
- {tailnetUrl && ( -
- Tailnet URL: - {tailnetUrl} -
- )} - {accessCode && ( -
- Remote access code: - {accessCode} -
- )} - {tunnelShareLink?.qrSvg && ( -
- Scan to connect: - Remote access QR code -
- )} -
- ); - })()} - -
-
- - -
- {!activeProvider && Select a provider above to configure remote access.} -
- - {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && ( -
-
- )} - - {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && ( -
-
- )} - - {activeProvider && ( -
- {activeProvider === "tailscale" ? ( - <> - Tailscale Funnel will expose this dashboard on your tailnet's public {`https://..ts.net/`} URL — no hostname or port configuration needed. - - - ) : ( - <> - - {(remoteForm.remoteCloudflareQuickTunnel ?? true) - ? "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed." - : "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below."} - -
{ - const detailsOpen = event.currentTarget.open; - setForm((f) => { - const currentQuickTunnel = Boolean((f as Record).remoteCloudflareQuickTunnel ?? true); - const nextQuickTunnel = !detailsOpen; - if (currentQuickTunnel === nextQuickTunnel) { - return f; - } - return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState; - }); - }} - > - Advanced (Named Tunnel) - {!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? ( -
- - setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} /> - - setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} /> - - setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} /> -
- ) : null} -
- - )} -
- )} - -
- {tunnelState === "running" || tunnelState === "starting" ? ( - - ) : ( - <> - {externalTunnel ? ( -
- - -
- ) : ( - - )} - {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? ( - cloudflared must be installed to start the tunnel - ) : null} - - )} -
- -
- Advanced Settings -
- - - setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} /> - {remoteShortLivedToken && Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)} -
-
- - Automatically restore tunnel on startup if it was running when last stopped. -
-
- -
- - - - -
- - - - URL and QR generation use the selected token type. - {remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""} - - {remoteUrlPreview?.url && ( - <> - Authenticated URL:{remoteUrlPreview.url} - - Token type: {remoteUrlPreview.tokenType} - {remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"} - - - )} - {remoteQrSvg && ( -
-

Scan this QR code on your phone

-
- Remote access QR code -
-
- QR SVG markup -
{remoteQrSvg}
-
-
- )} -
-
- + ); - } case "prompts": return ( <> @@ -7135,374 +5470,42 @@ export function SettingsModal({ ); - case "authentication": { - // CLI-backed providers (currently just claude-cli) render their own - // compact card with Enable/Disable + Test actions — bypassing the - // OAuth/API-key rendering below. Filter them out of the standard - // sort and render alongside. - const cliAuthProviders = authProviders.filter((p) => p.type === "cli"); - const nonCliProviders = authProviders.filter((p) => p.type !== "cli"); - // Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name. - const sortedProviders = [...nonCliProviders].sort((a, b) => { - if (a.authenticated !== b.authenticated) { - return a.authenticated ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - const authenticatedProviders = sortedProviders.filter(p => p.authenticated); - const unauthenticatedProviders = sortedProviders.filter(p => !p.authenticated); - - // CLI-backed providers live in whichever bucket matches their current - // auth state (Authenticated when signed in, Available otherwise). - const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); - const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); - const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); - const claudeCliCard = claudeCliProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const cursorCliCard = cursorCliProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const llamaCppCard = llamaCppProvider ? ( - { - void loadAuthStatus(); - }} - /> - ) : null; - const showAuthenticatedGroup = - authenticatedProviders.length > 0 - || (claudeCliProvider?.authenticated ?? false) - || (cursorCliProvider?.authenticated ?? false) - || (llamaCppProvider?.authenticated ?? false); - const showAvailableGroup = - unauthenticatedProviders.length > 0 - || (claudeCliProvider && !claudeCliProvider.authenticated) - || (cursorCliProvider && !cursorCliProvider.authenticated) - || (llamaCppProvider && !llamaCppProvider.authenticated); + case "authentication": return ( - <> -

{t("settings.auth.title", "Authentication")}

- {authLoading ? ( -
{t("settings.auth.loadingStatus", "Loading authentication status…")}
- ) : authProviders.length === 0 ? ( -
- {t("settings.auth.noProviders", "No providers available")} -
- ) : ( -
- { void loadAuthStatus(); } }} - /> - { void loadAuthStatus(); } }} - /> - {!showAuthenticatedGroup && ( -
- {t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")} -
- )} - {showAuthenticatedGroup && ( -
-
{t("settings.auth.groupAuthenticated", "Authenticated")}
- {claudeCliProvider?.authenticated && claudeCliCard} - {cursorCliProvider?.authenticated && cursorCliCard} - {llamaCppProvider?.authenticated && llamaCppCard} - {authenticatedProviders.map((provider) => ( -
-
-
- {/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} - - {provider.name} - - {t("settings.auth.statusActive", "✓ Active")} - - {provider.authenticated && provider.keyHint && ( - Key: {provider.keyHint} - )} -
- {provider.type === "api_key" ? ( -
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} - disabled={authActionInProgress === provider.id} - /> - {provider.authenticated && !apiKeyInputs[provider.id] ? ( - - ) : ( - - )} -
- {authActionInProgress === provider.id && ( - {t("settings.auth.savingKey", "Saving…")} - )} - {apiKeyErrors[provider.id] && ( - {apiKeyErrors[provider.id]} - )} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - - {opencodeApiKeyRefreshStatus[provider.id].message} - - )} -
- ) : ( -
- {authActionInProgress === provider.id ? ( - - ) : provider.loginInProgress ? ( -
- - -
- ) : ( - - )} -
- )} -
-
- ))} -
- )} - {showAvailableGroup && ( -
-
{t("settings.auth.groupAvailable", "Available")}
- {claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} - {cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard} - {llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard} - {unauthenticatedProviders.map((provider) => ( -
-
-
- {/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} - - {provider.name} - - {t("settings.auth.statusNotConnected", "✗ Not connected")} - -
- {provider.type === "api_key" ? ( -
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} - disabled={authActionInProgress === provider.id} - /> - -
- {authActionInProgress === provider.id && ( - {t("settings.auth.savingKey", "Saving…")} - )} - {apiKeyErrors[provider.id] && ( - {apiKeyErrors[provider.id]} - )} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - - {opencodeApiKeyRefreshStatus[provider.id].message} - - )} -
- ) : ( -
- {authActionInProgress === provider.id ? ( - - ) : provider.loginInProgress ? ( -
- - -
- ) : ( - - )} - {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( -
- {t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")} -
{deviceCodes[provider.id].userCode}
-
- - -
-
- )} - {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( - - )} - {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( - setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} - onSubmit={() => void handleSubmitManualCode(provider.id)} - prompt={manualCodeConfigs[provider.id].prompt} - placeholder={manualCodeConfigs[provider.id].placeholder} - helpText={manualCodeConfigs[provider.id].helpText} - disabled={manualCodeSubmitInProgress === provider.id} - submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} - data-testid={`auth-manual-code-${provider.id}`} - /> - )} -
- )} -
-
- ))} -
- )} -
- )} - - {t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")} - - {onReopenOnboarding && ( -
- - - {t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")} - -
- )} - - - - + ); - } case "hermes-runtime": - return ( - <> -

Hermes Runtime

- - - ); + return ; case "openclaw-runtime": - return ( - <> -

OpenClaw Runtime

- - - ); + return ; case "paperclip-runtime": - return ( - <> -

Paperclip Runtime

- - - ); + return ; } }; diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts new file mode 100644 index 0000000000..193f4c9a91 --- /dev/null +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -0,0 +1,120 @@ +/** + * Save-split logic for SettingsModal (U9 / KTD-10). + * + * The modal edits a single merged form that mixes global-scope and + * project-scope keys. On save it must split that form into two patches with + * strict scope separation and preserve three subtle semantics: + * + * 1. Global keys are routed via {@link isGlobalSettingsKey} to the global + * patch; project keys via {@link isProjectSettingsKey} to the project + * patch. (A key can be neither — server-only/UI-only fields are dropped.) + * 2. null-as-delete: an explicit clear (current value `undefined`, but the + * initial value was defined) is written as `null` so it survives + * `JSON.stringify` and tells the server to delete the key. Plain + * `undefined` is dropped. + * 3. changed-only project writes: an inherited/effective project value that + * the user never touched is NOT serialized as an explicit override — + * doing so would silently break inheritance for every project setting on + * every save. Only keys whose value differs from the initial project-scoped + * value are written. + * + * This module is pure (no React, no network) so the regression-critical split + * behavior is characterized in isolation; the modal shell calls it and performs + * the actual `updateGlobalSettings`/`updateSettings` writes. + */ +import { isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core"; +import type { GlobalSettings, Settings } from "@fusion/core"; + +/** Model-lane keys whose project overrides track inheritance explicitly. */ +export const MODEL_LANE_KEYS = [ + "planningProvider", "planningModelId", + "validatorProvider", "validatorModelId", + "executionProvider", "executionModelId", + "titleSummarizerProvider", "titleSummarizerModelId", + "defaultProviderOverride", "defaultModelIdOverride", + "planningFallbackProvider", "planningFallbackModelId", + "validatorFallbackProvider", "validatorFallbackModelId", + "titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", +] as const; + +const MODEL_LANE_KEY_SET = new Set(MODEL_LANE_KEYS); + +export interface SaveSplitInput { + /** The fully-normalized form payload (after trimming/normalization). */ + payload: Record; + /** Initial merged settings, used to detect explicit clears of global keys. */ + initialValues: Settings | null; + /** Initial scoped values, used to detect changed/cleared project overrides. */ + initialScopedValues: { global: GlobalSettings; project: Partial } | null; + /** The active section id; gates where `githubTrackingDefaultRepo` is written. */ + activeSection: string; +} + +export interface SaveSplitResult { + globalPatch: Partial; + projectPatch: Partial; +} + +/** + * Split a normalized settings form payload into global and project patches, + * preserving null-as-delete and changed-only-project-write semantics. + */ +export function splitSettingsSave({ + payload, + initialValues, + initialScopedValues, + activeSection, +}: SaveSplitInput): SaveSplitResult { + const globalPatch: Partial = {}; + for (const [key, value] of Object.entries(payload)) { + if (key === "githubTrackingDefaultRepo" && activeSection !== "global-general") { + continue; + } + if (key === "persistAgentThinkingLog") { + continue; + } + if (isGlobalSettingsKey(key)) { + // null-as-delete: explicit clear is sent as null, plain undefined dropped. + const initialValue = initialValues?.[key as keyof GlobalSettings]; + if (value === undefined && initialValue !== undefined) { + (globalPatch as Record)[key] = null; + } else { + (globalPatch as Record)[key] = value; + } + } + } + + const projectPatch: Partial = {}; + for (const [key, value] of Object.entries(payload)) { + if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only + if (key === "githubTrackingDefaultRepo" && activeSection === "global-general") continue; + if (!isProjectSettingsKey(key)) continue; + + const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings]; + + if (MODEL_LANE_KEY_SET.has(key)) { + if (value !== initialProjectValue) { + if ( + (value === undefined || value === null) && + initialProjectValue !== undefined && + initialProjectValue !== null + ) { + (projectPatch as Record)[key] = null; + } else if (value !== undefined) { + (projectPatch as Record)[key] = value; + } + } + } else { + // Changed-only gate + null-as-delete for non-model project settings. + if (value !== initialProjectValue) { + if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) { + (projectPatch as Record)[key] = null; + } else if (value !== undefined) { + (projectPatch as Record)[key] = value; + } + } + } + } + + return { globalPatch, projectPatch }; +} diff --git a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx new file mode 100644 index 0000000000..7ce95b1df9 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx @@ -0,0 +1,82 @@ +/** + * Appearance section (U9 / KTD-10). + * + * Theme mode, color theme, dashboard font scale, language, and the + * session-banner suppression toggle. The three-tier device-local prefs + * (theme/language/font scale) keep their hooks in the shell — this section only + * relays their current values and change callbacks, mirroring the original + * inline JSX exactly (it both writes the modal form AND calls the write-through + * callback so the live UI updates immediately). + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { ThemeMode, ColorTheme } from "@fusion/core"; +import { ThemeSelector } from "../../ThemeSelector"; +import { LanguageSelector } from "../../LanguageSelector"; +import type { SectionBaseProps } from "./context"; + +export interface AppearanceSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + themeMode: ThemeMode; + colorTheme: ColorTheme; + dashboardFontScalePct: number; + onThemeModeChange?: (mode: ThemeMode) => void; + onColorThemeChange?: (theme: ColorTheme) => void; + onDashboardFontScaleChange?: (scalePct: number) => void; + sessionBannersHidden: boolean; + setSessionBannersHidden: (hidden: boolean) => void; +} + +export function AppearanceSection({ + scopeBanner, + setForm, + themeMode, + colorTheme, + dashboardFontScalePct, + onThemeModeChange, + onColorThemeChange, + onDashboardFontScaleChange, + sessionBannersHidden, + setSessionBannersHidden, +}: AppearanceSectionProps) { + const { t } = useTranslation("app"); + return ( + <> + {scopeBanner} +

{t("settings.appearance.title", "Appearance")}

+ { + setForm((f) => ({ ...f, themeMode: mode })); + onThemeModeChange?.(mode); + }} + onColorThemeChange={(theme) => { + setForm((f) => ({ ...f, colorTheme: theme })); + onColorThemeChange?.(theme); + }} + onDashboardFontScaleChange={(scalePct) => { + setForm((f) => ({ ...f, dashboardFontScalePct: scalePct })); + onDashboardFontScaleChange?.(scalePct); + }} + /> + +
+ + + Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. + +
+ + ); +} + +export default AppearanceSection; diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx new file mode 100644 index 0000000000..4efbc9a877 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -0,0 +1,428 @@ +/** + * Authentication section (U9 / KTD-10). + * + * Provider sign-in surface: CLI-backed provider cards, OAuth login/logout + * flows (device codes, manual code entry, login instructions), API-key + * entry/clear, plugin-contributed provider/integration cards, and the custom + * providers manager. This section is scope-less (auth changes apply + * immediately, not via the modal save), so it owns no form state — but it has a + * large set of shell-owned auth state and handlers, relayed via the `auth` prop + * bag. Component imports and pure utilities (clipboard, token-query) are + * imported directly. Behavior, test ids, and i18n keys are preserved verbatim. + */ +import type { Dispatch, SetStateAction } from "react"; +import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo } from "../../../api"; +import type { ToastType } from "../../../hooks/useToast"; +import { useTranslation } from "react-i18next"; +import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard"; +import { CursorCliProviderCard } from "../../CursorCliProviderCard"; +import { LlamaCppProviderCard } from "../../LlamaCppProviderCard"; +import { ProviderIcon } from "../../ProviderIcon"; +import { PluginSlot } from "../../PluginSlot"; +import { LoginInstructions } from "../../LoginInstructions"; +import { OAuthManualCodeForm } from "../../OAuthManualCodeForm"; +import { CustomProvidersSection } from "../../CustomProvidersSection"; +import { copyTextToClipboard } from "../../../utils/copyToClipboard"; +import { appendTokenQuery } from "../../../auth"; + +export interface AuthenticationSectionData { + projectId?: string; + addToast: (message: string, type?: ToastType) => void; + authProviders: AuthProvider[]; + authLoading: boolean; + authActionInProgress: string | null; + apiKeyInputs: Record; + setApiKeyInputs: Dispatch>>; + apiKeyErrors: Record; + opencodeApiKeyRefreshStatus: Record; + deviceCodes: Record; + loginInstructions: Record; + manualCodeConfigs: Record; + manualCodeInputs: Record; + setManualCodeInputs: Dispatch>>; + manualCodeSubmitInProgress: string | null; + loadAuthStatus: () => void | Promise; + handleLogin: (providerId: string) => void; + handleLogout: (providerId: string) => void; + handleCancelLogin: (providerId: string) => void; + handleSaveApiKey: (providerId: string) => void; + handleClearApiKey: (providerId: string) => void; + handleSubmitManualCode: (providerId: string) => void | Promise; + onReopenOnboarding?: () => void; +} + +export interface AuthenticationSectionProps { + auth: AuthenticationSectionData; +} + +export function AuthenticationSection({ auth }: AuthenticationSectionProps) { + const { t } = useTranslation("app"); + const { + projectId, + addToast, + authProviders, + authLoading, + authActionInProgress, + apiKeyInputs, + setApiKeyInputs, + apiKeyErrors, + opencodeApiKeyRefreshStatus, + deviceCodes, + loginInstructions, + manualCodeConfigs, + manualCodeInputs, + setManualCodeInputs, + manualCodeSubmitInProgress, + loadAuthStatus, + handleLogin, + handleLogout, + handleCancelLogin, + handleSaveApiKey, + handleClearApiKey, + handleSubmitManualCode, + onReopenOnboarding, + } = auth; + + // CLI-backed providers render their own compact card; filter them out of the + // standard OAuth/API-key sort and render alongside. + const cliAuthProviders = authProviders.filter((p) => p.type === "cli"); + const nonCliProviders = authProviders.filter((p) => p.type !== "cli"); + const sortedProviders = [...nonCliProviders].sort((a, b) => { + if (a.authenticated !== b.authenticated) { + return a.authenticated ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + const authenticatedProviders = sortedProviders.filter((p) => p.authenticated); + const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated); + + const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); + const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); + const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); + const claudeCliCard = claudeCliProvider ? ( + { + void loadAuthStatus(); + }} + /> + ) : null; + const cursorCliCard = cursorCliProvider ? ( + { + void loadAuthStatus(); + }} + /> + ) : null; + const llamaCppCard = llamaCppProvider ? ( + { + void loadAuthStatus(); + }} + /> + ) : null; + const showAuthenticatedGroup = + authenticatedProviders.length > 0 || + (claudeCliProvider?.authenticated ?? false) || + (cursorCliProvider?.authenticated ?? false) || + (llamaCppProvider?.authenticated ?? false); + const showAvailableGroup = + unauthenticatedProviders.length > 0 || + (claudeCliProvider && !claudeCliProvider.authenticated) || + (cursorCliProvider && !cursorCliProvider.authenticated) || + (llamaCppProvider && !llamaCppProvider.authenticated); + + return ( + <> +

{t("settings.auth.title", "Authentication")}

+ {authLoading ? ( +
{t("settings.auth.loadingStatus", "Loading authentication status…")}
+ ) : authProviders.length === 0 ? ( +
+ {t("settings.auth.noProviders", "No providers available")} +
+ ) : ( +
+ { void loadAuthStatus(); } }} + /> + { void loadAuthStatus(); } }} + /> + {!showAuthenticatedGroup && ( +
+ {t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")} +
+ )} + {showAuthenticatedGroup && ( +
+
{t("settings.auth.groupAuthenticated", "Authenticated")}
+ {claudeCliProvider?.authenticated && claudeCliCard} + {cursorCliProvider?.authenticated && cursorCliCard} + {llamaCppProvider?.authenticated && llamaCppCard} + {authenticatedProviders.map((provider) => ( +
+
+
+ {/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} + + {provider.name} + + {t("settings.auth.statusActive", "✓ Active")} + + {provider.authenticated && provider.keyHint && ( + Key: {provider.keyHint} + )} +
+ {provider.type === "api_key" ? ( +
+
+ setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} + disabled={authActionInProgress === provider.id} + /> + {provider.authenticated && !apiKeyInputs[provider.id] ? ( + + ) : ( + + )} +
+ {authActionInProgress === provider.id && ( + {t("settings.auth.savingKey", "Saving…")} + )} + {apiKeyErrors[provider.id] && ( + {apiKeyErrors[provider.id]} + )} + {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( + + {opencodeApiKeyRefreshStatus[provider.id].message} + + )} +
+ ) : ( +
+ {authActionInProgress === provider.id ? ( + + ) : provider.loginInProgress ? ( +
+ + +
+ ) : ( + + )} +
+ )} +
+
+ ))} +
+ )} + {showAvailableGroup && ( +
+
{t("settings.auth.groupAvailable", "Available")}
+ {claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} + {cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard} + {llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard} + {unauthenticatedProviders.map((provider) => ( +
+
+
+ {/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} + + {provider.name} + + {t("settings.auth.statusNotConnected", "✗ Not connected")} + +
+ {provider.type === "api_key" ? ( +
+
+ setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} + disabled={authActionInProgress === provider.id} + /> + +
+ {authActionInProgress === provider.id && ( + {t("settings.auth.savingKey", "Saving…")} + )} + {apiKeyErrors[provider.id] && ( + {apiKeyErrors[provider.id]} + )} + {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( + + {opencodeApiKeyRefreshStatus[provider.id].message} + + )} +
+ ) : ( +
+ {authActionInProgress === provider.id ? ( + + ) : provider.loginInProgress ? ( +
+ + +
+ ) : ( + + )} + {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( +
+ {t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")} +
{deviceCodes[provider.id].userCode}
+
+ + +
+
+ )} + {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( + + )} + {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( + setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} + onSubmit={() => void handleSubmitManualCode(provider.id)} + prompt={manualCodeConfigs[provider.id].prompt} + placeholder={manualCodeConfigs[provider.id].placeholder} + helpText={manualCodeConfigs[provider.id].helpText} + disabled={manualCodeSubmitInProgress === provider.id} + submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} + data-testid={`auth-manual-code-${provider.id}`} + /> + )} +
+ )} +
+
+ ))} +
+ )} +
+ )} + + {t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")} + + {onReopenOnboarding && ( +
+ + + {t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")} + +
+ )} + + + + ); +} + +export default AuthenticationSection; diff --git a/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx b/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx new file mode 100644 index 0000000000..c80f4062c5 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ExperimentalSection.tsx @@ -0,0 +1,94 @@ +/** + * Experimental Features section (U9 / KTD-10). + * + * Renders the union of well-known experimental flags (always shown) and any + * custom flags present in settings, canonicalizing legacy aliases so each + * feature renders exactly one row. Toggling writes the canonical key and clears + * its legacy alias. The known-feature catalog and alias helpers live in the + * shell module and are passed in so this section stays presentational. + */ +import type { ReactNode } from "react"; +import type { SectionBaseProps } from "./context"; + +export interface ExperimentalSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + /** Display labels for well-known features (always rendered). */ + knownFeatures: Record; + /** Map of legacy alias key -> canonical key. */ + legacyAliases: Record; + /** Canonicalize a possibly-legacy feature key. */ + getCanonicalKey: (key: string) => string; + /** Whether a feature is enabled, honoring legacy aliases. */ + isFeatureEnabled: (features: Record, key: string) => boolean; +} + +export function ExperimentalSection({ + scopeBanner, + form, + setForm, + knownFeatures, + legacyAliases, + getCanonicalKey, + isFeatureEnabled, +}: ExperimentalSectionProps) { + const experimentalFeatures = form.experimentalFeatures ?? {}; + const allFeatureKeys = Array.from( + new Set([ + ...Object.keys(knownFeatures), + ...Object.keys(experimentalFeatures).map(getCanonicalKey), + ]), + ).sort((a, b) => a.localeCompare(b)); + const featureFlags = allFeatureKeys.map( + (key) => [key, isFeatureEnabled(experimentalFeatures, key)] as const, + ); + + return ( + <> + {scopeBanner} +

Experimental Features

+
+ + Experimental features are early capabilities that are not yet fully stable. + Enable them to test new functionality, but be aware they may change or be removed. + +
+ +
+ +
+ {featureFlags.map(([key, enabled]) => ( + + ))} +
+
+ + ); +} + +export default ExperimentalSection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx new file mode 100644 index 0000000000..380ffecc47 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -0,0 +1,176 @@ +/** + * Global General section (U9 / KTD-10). + * + * The global default tracking repo, CLI binary panel, agent-log persistence + * toggles (tool output + thinking logs), the `fn` binary probe toggle, and the + * update-check controls. The tracking-repo option list/loading/error live in + * the shell (fetched on demand) and are relayed as props. The thinking-log + * resolution helper is imported directly from core. + */ +import type { ReactNode } from "react"; +import { resolvePersistAgentThinkingLog } from "@fusion/core"; +import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect"; +import { CliBinaryPanel } from "../../CliBinaryPanel"; +import type { SectionBaseProps } from "./context"; + +export interface GlobalGeneralSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + globalTrackingRepoOptions: TrackingRepoOption[]; + globalTrackingRepoLoading: boolean; + globalTrackingRepoError: string | null; +} + +export function GlobalGeneralSection({ + scopeBanner, + form, + setForm, + globalTrackingRepoOptions, + globalTrackingRepoLoading, + globalTrackingRepoError, +}: GlobalGeneralSectionProps) { + return ( + <> + {scopeBanner} +

General

+
+ + + setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined })) + } + /> + Projects inherit this value when they do not set a project default tracking repo. +
+ +
+ + + When disabled, tool rows are still logged but detailed tool payloads are omitted. + Very large tool payloads may still be clipped even when this stays enabled. + +
+
+
Save AI thinking logs
+ + + + Leave both thinking toggles off to keep the original default behavior. + This only controls persisted thinking rows and does not affect assistant text or tool rows. + +
+
+ + + When enabled, the dashboard probes for a globally-installed{" "} + fn / fusion CLI by spawning{" "} + <bin> --version. Disable this if your local + dev process is the source of truth and you don't want any + outdated globally-installed binary executed during the probe. + +
+

Updates

+
+ + + When enabled, Fusion checks npm for new versions of{" "} + @runfusion/fusion and shows update notices in the CLI and dashboard. + Cadence is governed by the frequency below. + +
+
+ + + + Controls how often the dashboard re-fetches the npm registry. + Use the version + refresh control in the header to trigger an + immediate check at any time. + +
+
+ + + When enabled (default), the dashboard automatically reloads when it + detects a new build version — either from server rebuilds or service + worker updates. Disable this to stay on the current version until you + manually refresh. + +
+ + ); +} + +export default GlobalGeneralSection; diff --git a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx new file mode 100644 index 0000000000..3693c1a23a --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx @@ -0,0 +1,437 @@ +/** + * Global Models section (U9 / KTD-10). + * + * Default + fallback model pickers, thinking-effort selector (only for reasoning + * models), the per-role global model lanes, startup model-sync toggles, and the + * OpenRouter advanced routing/attribution knobs. Model catalog, favorites, and + * the favorite-toggle handlers live in the shell (fetched + persisted there) and + * are relayed as props. The comma-list (de)serializers are reproduced locally as + * pure helpers — identical to the modal's. + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { THINKING_LEVELS } from "@fusion/core"; +import type { Settings, ThinkingLevel } from "@fusion/core"; +import type { ModelInfo } from "../../../api"; +import { CustomModelDropdown } from "../../CustomModelDropdown"; +import type { SectionBaseProps, ModelLane } from "./context"; + +function toCommaSeparatedInput(values?: string[]): string { + return values?.join(", ") ?? ""; +} + +function fromCommaSeparatedInput(value: string): string[] { + return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); +} + +export interface GlobalModelsSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + availableModels: ModelInfo[]; + modelsLoading: boolean; + /** Global model lanes (i.e. MODEL_LANES without the `default` lane). */ + globalModelLanes: ModelLane[]; + favoriteProviders: string[]; + favoriteModels: string[]; + onToggleFavorite: (provider: string) => void; + onToggleModelFavorite: (modelId: string) => void; +} + +export function GlobalModelsSection({ + scopeBanner, + form, + setForm, + availableModels, + modelsLoading, + globalModelLanes, + favoriteProviders, + favoriteModels, + onToggleFavorite, + onToggleModelFavorite, +}: GlobalModelsSectionProps) { + const { t } = useTranslation("app"); + const selectedValue = + form.defaultProvider && form.defaultModelId + ? `${form.defaultProvider}/${form.defaultModelId}` + : ""; + + return ( + <> + {scopeBanner} + + {/* --- Default Model --- */} +

Default Model

+ {modelsLoading ? ( +
{t("settings.models.loadingModels", "Loading available models…")}
+ ) : availableModels.length === 0 ? ( +
+ {t("settings.models.noModels", "No models available. Configure authentication first.")} +
+ ) : ( + <> +
+ + { + if (!val) { + setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); + } else { + const slashIdx = val.indexOf("/"); + setForm((f) => ({ + ...f, + defaultProvider: val.slice(0, slashIdx), + defaultModelId: val.slice(slashIdx + 1), + })); + } + }} + placeholder="Use default" + favoriteProviders={favoriteProviders} + onToggleFavorite={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> + Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. +
+ +
+ + { + if (!val) { + setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined })); + } else { + const slashIdx = val.indexOf("/"); + setForm((f) => ({ + ...f, + fallbackProvider: val.slice(0, slashIdx), + fallbackModelId: val.slice(slashIdx + 1), + })); + } + }} + placeholder="No fallback" + favoriteProviders={favoriteProviders} + onToggleFavorite={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> + Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. +
+ + )} + {(() => { + const selectedModel = availableModels.find( + (m) => m.provider === form.defaultProvider && m.id === form.defaultModelId, + ); + if (selectedModel && !selectedModel.reasoning) return null; + return ( +
+ + + Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. +
+ ); + })()} + + {availableModels.length > 0 && ( + <> +

Model Lanes

+

+ Global baseline models for each AI role. Project settings can override these per-project. +

+ {globalModelLanes.map((lane) => { + const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined; + const model = form[lane.globalModelKey as keyof Settings] as string | undefined; + const value = provider && model ? `${provider}/${model}` : ""; + + return ( +
+ + { + if (!selected) { + setForm((f) => ({ + ...f, + [lane.globalProviderKey]: undefined, + [lane.globalModelKey]: undefined, + })); + return; + } + + const slashIdx = selected.indexOf("/"); + setForm((f) => ({ + ...f, + [lane.globalProviderKey]: selected.slice(0, slashIdx), + [lane.globalModelKey]: selected.slice(slashIdx + 1), + })); + }} + placeholder="Use default" + favoriteProviders={favoriteProviders} + onToggleFavorite={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> + {lane.helperText} +
+ ); + })} + + )} + + {/* --- Startup Model Sync --- */} +

Startup Model Sync

+
+ + + When enabled, startup fetches the latest available models from the OpenRouter API so + model pickers always include the newest catalog. + +
+
+ + + When enabled, startup refreshes models through the local opencode models opencode --refresh + flow and publishes them under the opencode-go provider in model pickers. + +
+
+ OpenRouter advanced +
+ + setForm((f) => ({ + ...f, + openrouterAppAttribution: { + ...(f.openrouterAppAttribution || {}), + referer: e.target.value, + }, + }))} + /> + Leave empty to omit this header. Default: https://runfusion.ai. +
+
+ + setForm((f) => ({ + ...f, + openrouterAppAttribution: { + ...(f.openrouterAppAttribution || {}), + title: e.target.value, + }, + }))} + /> + Leave empty to omit this header. Default: Fusion. +
+
+ + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ + ...f, + openrouterModelFilters: { + ...(f.openrouterModelFilters || {}), + supported_parameters: parsed.length > 0 ? parsed : undefined, + }, + })); + }} + /> + Comma-separated values sent to OpenRouter model sync. +
+
+ + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ + ...f, + openrouterModelFilters: { + ...(f.openrouterModelFilters || {}), + output_modalities: parsed.length > 0 ? parsed : undefined, + }, + })); + }} + /> + Comma-separated values sent to OpenRouter model sync. +
+
+ + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ + ...f, + openrouterProviderPreferences: { + ...(f.openrouterProviderPreferences || {}), + order: parsed.length > 0 ? parsed : undefined, + }, + })); + }} + /> +
+
+ + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ + ...f, + openrouterProviderPreferences: { + ...(f.openrouterProviderPreferences || {}), + ignore: parsed.length > 0 ? parsed : undefined, + }, + })); + }} + /> +
+
+ + { + const parsed = fromCommaSeparatedInput(e.target.value); + setForm((f) => ({ + ...f, + openrouterProviderPreferences: { + ...(f.openrouterProviderPreferences || {}), + only: parsed.length > 0 ? parsed : undefined, + }, + })); + }} + /> +
+
+ + +
+
+ + +
+
+ +
+
+ + ); +} + +export default GlobalModelsSection; diff --git a/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx b/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx new file mode 100644 index 0000000000..20ea42c4df --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/NodeSyncSection.tsx @@ -0,0 +1,101 @@ +/** + * Node Sync section (U9 / KTD-10). + * + * Cross-node settings synchronization toggles. Preserves the existing + * "Workflow settings are not synced across nodes yet" informational note + * (KTD-8) verbatim, including its i18n key. + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { SectionBaseProps } from "./context"; + +export interface NodeSyncSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; +} + +export function NodeSyncSection({ scopeBanner, form, setForm }: NodeSyncSectionProps) { + const { t } = useTranslation("app"); + return ( + <> + {scopeBanner} +

Node Sync

+
+ + Automatically synchronize settings between this node and connected remote nodes +
+ {form.settingsSyncEnabled && ( + <> +
+ + Include API keys and OAuth tokens in sync operations +
+
+ + +
+
+ + +
+ + )} + {/* KTD-8: workflow settings are not yet part of the cross-node sync + channel. Non-dismissible, informational only, no action affordance. */} +

+ {t( + "settings.nodeSync.workflowSettingsNotSynced", + "Workflow settings are not synced across nodes yet.", + )} +

+ + ); +} + +export default NodeSyncSection; diff --git a/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx b/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx new file mode 100644 index 0000000000..7804899f33 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/NotificationsSection.tsx @@ -0,0 +1,408 @@ +/** + * Notifications section (U9 / KTD-10). + * + * Failure-notification policy plus the ntfy and webhook provider cards, + * including per-event toggles and the "test notification" affordances. The + * test-send handler and its loading/result state live in the shell (they touch + * the API and toast); this section relays them as props. Behavior, keys, and + * validation regexes are preserved verbatim from the original inline JSX. + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { NtfyNotificationEvent } from "@fusion/core"; +import type { SectionBaseProps } from "./context"; + +/** Default event set used when a provider has no explicit `*Events` override. */ +export const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [ + "in-review", + "merged", + "failed", + "awaiting-approval", + "awaiting-user-review", + "planning-awaiting-input", + "gridlock", + "fallback-used", + "memory-dreams-processed", + "message:agent-to-user", + "message:agent-to-agent", + "message:room", + "oauth-token-expired", +]; + +export const NOTIFICATION_EVENT_OPTIONS: Array<{ + event: NtfyNotificationEvent; + label: string; + description: string; +}> = [ + { event: "in-review", label: "Task completed (in-review)", description: "When a task moves to In Review (ready for review)" }, + { event: "merged", label: "Task merged", description: "When a task is successfully merged to main" }, + { event: "failed", label: "Task failed", description: "When a task fails during execution (high priority)" }, + { event: "awaiting-approval", label: "Plan needs approval", description: "When a task specification needs manual approval before execution" }, + { event: "awaiting-user-review", label: "User review needed", description: "When an agent hands off a task for human review (high priority)" }, + { event: "planning-awaiting-input", label: "Planning needs input", description: "When planning mode is waiting for your response to continue" }, + { event: "gridlock", label: "Pipeline gridlocked", description: "When all schedulable todo tasks are blocked and work cannot advance" }, + { event: "fallback-used", label: "Fallback model used (recovered)", description: "When Fusion recovers from a retryable model failure by switching to a fallback model" }, + { event: "task-created", label: "Agent created a task", description: "When an agent files a new task on the board" }, + { event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" }, + { event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" }, + { event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" }, + { event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" }, + { event: "oauth-token-expired", label: "OAuth token expired", description: "Notify when a provider OAuth token (Codex, Claude, etc.) expires." }, +]; + +export type TestNotificationProvider = "ntfy" | "webhook" | "ntfy-message" | "ntfy-room"; + +export interface NotificationsSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + testNotificationLoading: Record; + testNotificationResult: Record; + onTestProviderNotification: (provider: TestNotificationProvider) => void; +} + +export function NotificationsSection({ + scopeBanner, + form, + setForm, + testNotificationLoading, + testNotificationResult, + onTestProviderNotification, +}: NotificationsSectionProps) { + const { t } = useTranslation("app"); + return ( + <> + {scopeBanner} +

Notifications

+ +
+
+ + + Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts. +
+
+ + { + const parsed = Number(e.target.value); + setForm((f) => ({ + ...f, + failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0, + })); + }} + /> + + How long a failure must persist before a push notification is sent. 0 = notify immediately. + +
+
+ +
+
+ ntfy + +
+ {form.ntfyEnabled && ( +
+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, ntfyTopic: val || undefined })); + }} + /> + + Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "} + + Learn more about ntfy.sh + + + {form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && ( + + Topic must be 1–64 alphanumeric, hyphen, or underscore characters + + )} +
+ Advanced +
+ + { + const value = e.target.value; + setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined })); + }} + /> + + Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://. + + + { + const value = e.target.value; + setForm((f) => ({ ...f, ntfyAccessToken: value || undefined })); + }} + /> + + Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests. + +
+
+
+
+ +
+ {NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { + const checked = form.ntfyEvents?.includes(event) ?? true; + return ( +
+ + {description} +
+ ); + })} +
+
+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined })); + }} + /> + + Base URL for deep links in notifications. When set, clicking a notification + opens the dashboard directly to the task. + + {form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && ( + + Must be a valid URL starting with http:// or https:// + + )} +
+
+ + + +
+ {(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && ( +
+ {testNotificationResult["ntfy"] && ( + + General: {testNotificationResult["ntfy"].message} + + )} + {testNotificationResult["ntfy-message"] && ( + + Message inbox: {testNotificationResult["ntfy-message"].message} + + )} + {testNotificationResult["ntfy-room"] && ( + + Room reply: {testNotificationResult["ntfy-room"].message} + + )} +
+ )} +
+ )} +
+ +
+
+ Webhook + +
+ {form.webhookEnabled && ( +
+
+ + { + const val = e.target.value; + setForm((f) => ({ ...f, webhookUrl: val || undefined })); + }} + /> +
+
+ + +
+
+ +
+ {NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => { + const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS]; + const checked = currentEvents.includes(event); + return ( +
+ + {description} +
+ ); + })} +
+
+
+ +
+ {testNotificationResult["webhook"] && ( +
+ + {testNotificationResult["webhook"].message} + +
+ )} +
+ )} +
+ + ); +} + +export default NotificationsSection; diff --git a/packages/dashboard/app/components/settings/sections/RemoteSection.tsx b/packages/dashboard/app/components/settings/sections/RemoteSection.tsx new file mode 100644 index 0000000000..028687a0b1 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/RemoteSection.tsx @@ -0,0 +1,424 @@ +/** + * Remote Access section (U9 / KTD-10). + * + * Tunnel status, provider selection (Tailscale / Cloudflare), cloudflared + * install affordance, start/stop/use-existing flows, and the auth-link / QR + * tooling. This section is heavily stateful and side-effecting; rather than + * lift all of that into the section, the shell continues to own the remote + * state machine (status polling, busy-action guard, install handler, token + * previews) and the `runRemoteAction` wrapper. The section receives them via a + * single `remote` prop bag plus the modal form. API calls are imported directly + * here (pure module functions) so they don't bloat the prop surface. Behavior + * and i18n keys are preserved verbatim from the original inline JSX. + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Globe, CheckCircle, AlertTriangle } from "lucide-react"; +import { + updateRemoteSettings, + startRemoteTunnel, + stopRemoteTunnel, + killExternalTunnel, + regenerateRemotePersistentToken, + generateShortLivedRemoteToken, + fetchRemoteUrl, + fetchRemoteQr, + type RemoteSettings, + type RemoteStatus, +} from "../../../api"; +import type { ToastType } from "../../../hooks/useToast"; +import type { SectionBaseProps, SettingsFormState } from "./context"; + +export interface RemoteSectionData { + projectId?: string; + addToast: (message: string, type?: ToastType) => void; + remoteStatus: RemoteStatus | null; + externalTunnel: { provider: string; url: string | null } | null; + tunnelShareLink: { url: string; qrSvg: string | null } | null; + remoteBusyAction: string | null; + cloudflaredInstalling: boolean; + cloudflaredInstallError: string | null; + cloudflaredManualInstallCommand: () => string; + cloudflaredMacFallbackCommand: () => string | null; + handleInstallCloudflared: () => Promise; + runRemoteAction: (label: string, action: () => Promise) => Promise; + remoteShortLivedToken: { token: string; expiresAt: string; ttlMs: number } | null; + setRemoteShortLivedToken: (value: { token: string; expiresAt: string; ttlMs: number } | null) => void; + remoteAuthLinkTokenType: "persistent" | "short-lived"; + setRemoteAuthLinkTokenType: (value: "persistent" | "short-lived") => void; + remoteUrlPreview: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null; + setRemoteUrlPreview: ( + value: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived" } | null, + ) => void; + remoteQrSvg: string | null; + setRemoteQrSvg: (value: string | null) => void; +} + +export interface RemoteSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + remote: RemoteSectionData; +} + +export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSectionProps) { + const { t } = useTranslation("app"); + const { + projectId, + addToast, + remoteStatus, + externalTunnel, + tunnelShareLink, + remoteBusyAction, + cloudflaredInstalling, + cloudflaredInstallError, + cloudflaredManualInstallCommand, + cloudflaredMacFallbackCommand, + handleInstallCloudflared, + runRemoteAction, + remoteShortLivedToken, + setRemoteShortLivedToken, + remoteAuthLinkTokenType, + setRemoteAuthLinkTokenType, + remoteUrlPreview, + setRemoteUrlPreview, + remoteQrSvg, + setRemoteQrSvg, + } = remote; + + const remoteForm = form as Record; + const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null; + const tunnelState = (remoteStatus?.state as RemoteStatus["state"] | "error" | undefined) ?? "stopped"; + const statusColor = + tunnelState === "running" + ? "running" + : tunnelState === "starting" + ? "starting" + : tunnelState === "failed" || tunnelState === "error" + ? "error" + : "stopped"; + + const buildSavePayload = (provider: "tailscale" | "cloudflare"): Partial => { + const formState = form as Record; + return { + remoteActiveProvider: provider, + remoteTailscaleEnabled: provider === "tailscale", + remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""), + remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040), + remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes), + remoteCloudflareEnabled: provider === "cloudflare", + remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true), + remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""), + remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null, + remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""), + remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled), + remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000), + remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning), + }; + }; + + return ( + <> + {scopeBanner} +

Remote Access

+
+ + {tunnelState} + {remoteStatus?.provider && · {remoteStatus.provider}} + {remoteStatus?.url && {remoteStatus.url}} + {remoteStatus?.lastError && {remoteStatus.lastError}} +
+ {tunnelState === "stopped" && externalTunnel && ( +
+
+
+ {externalTunnel.url && {externalTunnel.url}} + {tunnelShareLink?.qrSvg && ( +
+ Scan to open: + External tunnel QR code +
+ )} +
+ )} + {tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => { + let accessCode: string | null = null; + let tailnetUrl: string | null = remoteStatus?.url ?? null; + if (tunnelShareLink?.url) { + try { + const parsed = new URL(tunnelShareLink.url); + accessCode = parsed.searchParams.get("rt"); + if (!tailnetUrl) tailnetUrl = `${parsed.origin}/`; + } catch { + // fall through + } + } + return ( +
+ {tailnetUrl && ( +
+ Tailnet URL: + {tailnetUrl} +
+ )} + {accessCode && ( +
+ Remote access code: + {accessCode} +
+ )} + {tunnelShareLink?.qrSvg && ( +
+ Scan to connect: + Remote access QR code +
+ )} +
+ ); + })()} + +
+
+ + +
+ {!activeProvider && Select a provider above to configure remote access.} +
+ + {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && ( +
+
+ )} + + {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && ( +
+
+ )} + + {activeProvider && ( +
+ {activeProvider === "tailscale" ? ( + <> + Tailscale Funnel will expose this dashboard on your tailnet's public {`https://..ts.net/`} URL — no hostname or port configuration needed. + + + ) : ( + <> + + {(remoteForm.remoteCloudflareQuickTunnel ?? true) + ? "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed." + : "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below."} + +
{ + const detailsOpen = event.currentTarget.open; + setForm((f) => { + const currentQuickTunnel = Boolean((f as Record).remoteCloudflareQuickTunnel ?? true); + const nextQuickTunnel = !detailsOpen; + if (currentQuickTunnel === nextQuickTunnel) { + return f; + } + return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState; + }); + }} + > + Advanced (Named Tunnel) + {!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? ( +
+ + setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} /> + + setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} /> + + setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} /> +
+ ) : null} +
+ + )} +
+ )} + +
+ {tunnelState === "running" || tunnelState === "starting" ? ( + + ) : ( + <> + {externalTunnel ? ( +
+ + +
+ ) : ( + + )} + {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? ( + cloudflared must be installed to start the tunnel + ) : null} + + )} +
+ +
+ Advanced Settings +
+ + + setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} /> + {remoteShortLivedToken && Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)} +
+
+ + Automatically restore tunnel on startup if it was running when last stopped. +
+
+ +
+ + + + +
+ + + + URL and QR generation use the selected token type. + {remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""} + + {remoteUrlPreview?.url && ( + <> + Authenticated URL:{remoteUrlPreview.url} + + Token type: {remoteUrlPreview.tokenType} + {remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"} + + + )} + {remoteQrSvg && ( +
+

Scan this QR code on your phone

+
+ Remote access QR code +
+
+ QR SVG markup +
{remoteQrSvg}
+
+
+ )} +
+
+ + ); +} + +export default RemoteSection; diff --git a/packages/dashboard/app/components/settings/sections/ResearchGlobalSection.tsx b/packages/dashboard/app/components/settings/sections/ResearchGlobalSection.tsx new file mode 100644 index 0000000000..4beb634170 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ResearchGlobalSection.tsx @@ -0,0 +1,270 @@ +/** + * Research Defaults (global) section (U9 / KTD-10). + * + * Global research web-search provider selection (built-in vs external), the + * external-provider advanced disclosure, default run limits, and enabled-source + * toggles. Credential-presence checks read the shell's fetched `authProviders`; + * the "open Authentication" affordances navigate via the shell's + * `onNavigateToSection` so cross-section deep-links keep working. + */ +import type { ReactNode } from "react"; +import type { Settings } from "@fusion/core"; +import type { AuthProvider } from "../../../api"; +import type { SectionId } from "../../SettingsModal"; +import type { SectionBaseProps } from "./context"; + +export interface ResearchGlobalSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; + authProviders: AuthProvider[]; + onNavigateToSection: (section: SectionId) => void; +} + +export function ResearchGlobalSection({ + scopeBanner, + form, + setForm, + authProviders, + onNavigateToSection, +}: ResearchGlobalSectionProps) { + const resolvedProvider = + form.researchGlobalWebSearchProvider ?? + form.researchGlobalDefaults?.searchProvider ?? + "builtin"; + const externalProvider = + resolvedProvider === "searxng" || + resolvedProvider === "brave" || + resolvedProvider === "google" || + resolvedProvider === "tavily"; + const selectedCredentialProvider = + resolvedProvider === "brave" || resolvedProvider === "tavily" ? resolvedProvider : null; + const hasMissingResearchCredential = selectedCredentialProvider + ? authProviders.some( + (provider) => provider.id === selectedCredentialProvider && !provider.authenticated, + ) + : false; + + const setSearchProvider = (provider: Settings["researchGlobalWebSearchProvider"]) => { + setForm((current) => ({ + ...current, + researchGlobalWebSearchProvider: provider, + researchGlobalDefaults: { + ...(current.researchGlobalDefaults ?? {}), + searchProvider: provider, + }, + })); + }; + + return ( + <> + {scopeBanner} +

Research Defaults

+
+ + + Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required. + +
+ Advanced — external search providers +
+
+ + +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalSearxngUrl: event.target.value || undefined, + })) + } + placeholder="https://searx.example.com" + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalGoogleSearchCx: event.target.value || undefined, + })) + } + placeholder="custom-search-engine-id" + /> +
+
+ Configure Brave, Tavily, and Google API keys in Authentication. + +
+
+
+
+
+
+
+ + + setForm((current) => ({ + ...current, + researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value), + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), + researchGlobalDefaults: { + ...(current.researchGlobalDefaults ?? {}), + maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value), + }, + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value), + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value), + })) + } + /> +
+
+ + + setForm((current) => ({ + ...current, + researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value), + })) + } + /> +
+
+
+
+ + +
+ + +
+
+ {hasMissingResearchCredential && ( +
+ Missing credentials for the selected research provider. + +
+ )} + + ); +} + +export default ResearchGlobalSection; diff --git a/packages/dashboard/app/components/settings/sections/RuntimesSections.tsx b/packages/dashboard/app/components/settings/sections/RuntimesSections.tsx new file mode 100644 index 0000000000..b684145e47 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/RuntimesSections.tsx @@ -0,0 +1,35 @@ +/** + * Runtimes group sections (U9 / KTD-10) — thin wrappers around each plugin + * runtime's self-contained card. These sections carry no modal form state; they + * just title and mount the runtime card relocated from SettingsModal's switch. + */ +import { HermesRuntimeCard } from "../../HermesRuntimeCard"; +import { OpenClawRuntimeCard } from "../../OpenClawRuntimeCard"; +import { PaperclipRuntimeCard } from "../../PaperclipRuntimeCard"; + +export function HermesRuntimeSection() { + return ( + <> +

Hermes Runtime

+ + + ); +} + +export function OpenClawRuntimeSection() { + return ( + <> +

OpenClaw Runtime

+ + + ); +} + +export function PaperclipRuntimeSection() { + return ( + <> +

Paperclip Runtime

+ + + ); +} diff --git a/packages/dashboard/app/components/settings/sections/context.ts b/packages/dashboard/app/components/settings/sections/context.ts new file mode 100644 index 0000000000..388a30b260 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/context.ts @@ -0,0 +1,51 @@ +/** + * Shared contract for the per-section components the SettingsModal shell composes + * (U9 / KTD-10). + * + * The shell owns ALL persistence: it holds the single merged `form`, the + * scope-aware save-split (see settings/save-split.ts), null-as-delete, and the + * changed-only project-write gate. Sections are presentational — they read from + * `form`/section-specific data and emit edits back through `setForm` (and a few + * section-scoped setters/handlers). They never call `updateSettings` / + * `updateGlobalSettings` themselves. + * + * This is intentionally a pragmatic prop bag rather than a redesigned state + * model: it mirrors exactly how the inline JSX read and wrote the modal's local + * state, so extraction is behavior-preserving. Each section's props interface + * extends {@link SectionBaseProps} and adds only the slice it needs. + */ +import type { Settings, GlobalSettings } from "@fusion/core"; + +/** A model-lane descriptor pairing a role's global and project override keys. + * Mirrors the `ModelLane` shape SettingsModal builds for its model pickers. */ +export interface ModelLane { + laneId: string; + label: string; + globalProviderKey: keyof GlobalSettings; + globalModelKey: keyof GlobalSettings; + projectProviderKey: keyof Settings; + projectModelKey: keyof Settings; + helperText: string; + fallbackOrder: string; +} + +/** Local form state extends Settings with a worktreeInitCommand override and + * lets tokenCap carry null (delete semantic). Mirrors SettingsModal's + * SettingsFormState. */ +export type SettingsFormState = Settings & { + worktreeInitCommand?: string; + tokenCap?: number | null; +}; + +/** State updater identical to React's `setState` for the modal form. */ +export type SetSettingsForm = ( + updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState), +) => void; + +/** Props every extracted section receives. */ +export interface SectionBaseProps { + /** The single merged settings form (global + project keys). */ + form: SettingsFormState; + /** Mutates the form; the shell's save-split decides scope + persistence. */ + setForm: SetSettingsForm; +}