refactor(dashboard): extract SettingsModal Account/Global/Runtimes sections into shared-primitive components with pure save-split helper

This commit is contained in:
gsxdsm
2026-06-05 00:15:15 -07:00
parent cb9a3f1cc5
commit 8c9702e909
15 changed files with 3090 additions and 2151 deletions

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { 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);
});
});

View File

@@ -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 (
<AppearanceSection
scopeBanner={null}
form={emptyForm}
setForm={vi.fn()}
themeMode="dark"
colorTheme="default"
dashboardFontScalePct={100}
sessionBannersHidden={hidden}
setSessionBannersHidden={setHidden}
/>
);
}
it("round-trips the session-banner toggle through its setter", () => {
render(<AppearanceHost />);
const toggle = screen.getByText("Hide AI session notification banners")
.closest("label")!
.querySelector("input[type=checkbox]") as HTMLInputElement;
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(
<NotificationsSection
scopeBanner={null}
form={emptyForm}
setForm={setForm}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
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(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: false } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
rerender(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: true } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
});
});
describe("ExperimentalSection", () => {
const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" };
const legacyAliases: Record<string, string> = { devServer: "devServerView" };
const getCanonicalKey = (k: string) => legacyAliases[k] ?? k;
const isFeatureEnabled = (features: Record<string, boolean>, 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<SettingsFormState>(
{ experimentalFeatures: {} } as SettingsFormState,
);
return (
<ExperimentalSection
scopeBanner={null}
form={form}
setForm={setFormState as never}
knownFeatures={knownFeatures}
legacyAliases={legacyAliases}
getCanonicalKey={getCanonicalKey}
isFeatureEnabled={isFeatureEnabled}
/>
);
}
it("renders a row per known flag and round-trips the canonical key", () => {
render(<ExperimentalHost />);
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);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>(MODEL_LANE_KEYS);
export interface SaveSplitInput {
/** The fully-normalized form payload (after trimming/normalization). */
payload: Record<string, unknown>;
/** Initial merged settings, used to detect explicit clears of global keys. */
initialValues: Settings | null;
/** Initial scoped values, used to detect changed/cleared project overrides. */
initialScopedValues: { global: GlobalSettings; project: Partial<Settings> } | null;
/** The active section id; gates where `githubTrackingDefaultRepo` is written. */
activeSection: string;
}
export interface SaveSplitResult {
globalPatch: Partial<GlobalSettings>;
projectPatch: Partial<Settings>;
}
/**
* 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<GlobalSettings> = {};
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<string, unknown>)[key] = null;
} else {
(globalPatch as Record<string, unknown>)[key] = value;
}
}
}
const projectPatch: Partial<Settings> = {};
for (const [key, value] of Object.entries(payload)) {
if (key === "githubTokenConfigured" || key === "prAuthAvailable") continue; // server-only
if (key === "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<string, unknown>)[key] = null;
} else if (value !== undefined) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}
} else {
// Changed-only gate + null-as-delete for non-model project settings.
if (value !== initialProjectValue) {
if (value === undefined && initialProjectValue !== undefined && initialProjectValue !== null) {
(projectPatch as Record<string, unknown>)[key] = null;
} else if (value !== undefined) {
(projectPatch as Record<string, unknown>)[key] = value;
}
}
}
}
return { globalPatch, projectPatch };
}

View File

@@ -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}
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
<ThemeSelector
themeMode={themeMode}
colorTheme={colorTheme}
dashboardFontScalePct={dashboardFontScalePct}
onThemeModeChange={(mode) => {
setForm((f) => ({ ...f, themeMode: mode }));
onThemeModeChange?.(mode);
}}
onColorThemeChange={(theme) => {
setForm((f) => ({ ...f, colorTheme: theme }));
onColorThemeChange?.(theme);
}}
onDashboardFontScaleChange={(scalePct) => {
setForm((f) => ({ ...f, dashboardFontScalePct: scalePct }));
onDashboardFontScaleChange?.(scalePct);
}}
/>
<LanguageSelector />
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={sessionBannersHidden}
onChange={(e) => setSessionBannersHidden(e.target.checked)}
/>
<span>Hide AI session notification banners</span>
</label>
<small className="form-text text-muted">
Suppress the &ldquo;needs your input&rdquo; banner that appears when AI sessions are awaiting input or have failed.
</small>
</div>
</>
);
}
export default AppearanceSection;

View File

@@ -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<string, string>;
setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>;
apiKeyErrors: Record<string, string>;
opencodeApiKeyRefreshStatus: Record<string, { tone: "success" | "error"; message: string }>;
deviceCodes: Record<string, OAuthDeviceCodeInfo>;
loginInstructions: Record<string, string>;
manualCodeConfigs: Record<string, ManualOAuthCodeInfo>;
manualCodeInputs: Record<string, string>;
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
manualCodeSubmitInProgress: string | null;
loadAuthStatus: () => void | Promise<void>;
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<void>;
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 ? (
<ClaudeCliProviderCard
compact
authenticated={claudeCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const cursorCliCard = cursorCliProvider ? (
<CursorCliProviderCard
compact
authenticated={cursorCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const llamaCppCard = llamaCppProvider ? (
<LlamaCppProviderCard
compact
authenticated={llamaCppProvider.authenticated}
onToggled={() => {
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 (
<>
<h4 className="settings-section-heading">{t("settings.auth.title", "Authentication")}</h4>
{authLoading ? (
<div className="settings-empty-state">{t("settings.auth.loadingStatus", "Loading authentication status…")}</div>
) : authProviders.length === 0 ? (
<div className="settings-empty-state settings-muted">
{t("settings.auth.noProviders", "No providers available")}
</div>
) : (
<div className="auth-panel-body">
<PluginSlot
slotId="settings-provider-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
<PluginSlot
slotId="settings-integration-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
{!showAuthenticatedGroup && (
<div className="auth-section-hint">
{t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")}
</div>
)}
{showAuthenticatedGroup && (
<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div>
{claudeCliProvider?.authenticated && claudeCliCard}
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
<span
className="auth-provider-icon-slot"
data-testid={`auth-provider-icon-${provider.id}`}
aria-hidden="true"
>
<ProviderIcon provider={provider.id} size="md" />
</span>
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{t("settings.auth.statusActive", "✓ Active")}
</span>
{provider.authenticated && provider.keyHint && (
<span className="auth-key-hint">Key: {provider.keyHint}</span>
)}
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{t("settings.auth.clearKey", "Clear")}
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{t("settings.actions.save", "Save")}
</button>
)}
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{t("settings.auth.loggingOut", "Logging out…")}
</button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
) : (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
{t("settings.auth.logout", "Logout")}
</button>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
{showAvailableGroup && (
<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
<span
className="auth-provider-icon-slot"
data-testid={`auth-provider-icon-${provider.id}`}
aria-hidden="true"
>
<ProviderIcon provider={provider.id} size="md" />
</span>
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{t("settings.auth.statusNotConnected", "✗ Not connected")}
</span>
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{t("settings.actions.save", "Save")}
</button>
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (
<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
{t("settings.auth.login", "Login")}
</button>
)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button
className="btn btn-sm"
onClick={() => {
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}
>
{t("settings.auth.copyCode", "Copy code")}
</button>
<button
className="btn btn-sm"
onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}
>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div>
</div>
)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<LoginInstructions
instructions={loginInstructions[provider.id]}
data-testid={`auth-login-instructions-${provider.id}`}
/>
)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => 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}`}
/>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
<small className="auth-hint">
{t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")}
</small>
{onReopenOnboarding && (
<div className="form-group" style={{ marginTop: "var(--space-md)" }}>
<button
type="button"
className="btn btn-sm"
onClick={onReopenOnboarding}
>
{t("settings.auth.reopenOnboarding", "Reopen onboarding guide")}
</button>
<small className="settings-muted">
{t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")}
</small>
</div>
)}
<CustomProvidersSection />
</>
);
}
export default AuthenticationSection;

View File

@@ -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<string, string>;
/** Map of legacy alias key -> canonical key. */
legacyAliases: Record<string, string>;
/** Canonicalize a possibly-legacy feature key. */
getCanonicalKey: (key: string) => string;
/** Whether a feature is enabled, honoring legacy aliases. */
isFeatureEnabled: (features: Record<string, boolean>, 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}
<h4 className="settings-section-heading">Experimental Features</h4>
<div className="form-group">
<small>
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.
</small>
</div>
<div className="form-group">
<label>Feature Flags</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{featureFlags.map(([key, enabled]) => (
<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input
id={`experimental-${key}`}
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => {
const nextExperimentalFeatures = {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
};
for (const [legacyKey, canonicalKey] of Object.entries(legacyAliases)) {
if (canonicalKey === key) {
delete nextExperimentalFeatures[legacyKey];
}
}
return {
...f,
experimentalFeatures: nextExperimentalFeatures,
};
});
}}
/>
<span>{knownFeatures[key] ?? key}</span>
</label>
))}
</div>
</div>
</>
);
}
export default ExperimentalSection;

View File

@@ -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}
<h4 className="settings-section-heading">General</h4>
<div className="form-group">
<label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label>
<TrackingRepoSelect
id="globalGithubTrackingDefaultRepo"
ariaLabel="Global default tracking repo"
value={form.githubTrackingDefaultRepo ?? ""}
options={globalTrackingRepoOptions}
loading={globalTrackingRepoLoading}
error={globalTrackingRepoError ?? undefined}
placeholder="owner/repo"
onChange={(nextValue) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
}
/>
<small>Projects inherit this value when they do not set a project default tracking repo.</small>
</div>
<CliBinaryPanel />
<div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
<input
id="persistAgentToolOutput"
type="checkbox"
checked={form.persistAgentToolOutput !== false}
onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}
/>
Save tool output in agent logs
</label>
<small>
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.
</small>
</div>
<div className="form-group">
<h5 className="settings-section-heading">Save AI thinking logs</h5>
<label htmlFor="persistAgentThinkingLogPermanent" className="checkbox-label">
<input
id="persistAgentThinkingLogPermanent"
type="checkbox"
checked={resolvePersistAgentThinkingLog(form, { ephemeral: false })}
onChange={(e) =>
setForm((f) => ({ ...f, persistAgentThinkingLogPermanent: e.target.checked }))
}
/>
Save AI thinking for permanent agents
</label>
<label htmlFor="persistAgentThinkingLogEphemeral" className="checkbox-label">
<input
id="persistAgentThinkingLogEphemeral"
type="checkbox"
checked={resolvePersistAgentThinkingLog(form, { ephemeral: true })}
onChange={(e) =>
setForm((f) => ({ ...f, persistAgentThinkingLogEphemeral: e.target.checked }))
}
/>
Save AI thinking for ephemeral / task-worker agents
</label>
<small>
Leave both thinking toggles off to keep the original default behavior.
This only controls persisted <code>thinking</code> rows and does not affect assistant text or tool rows.
</small>
</div>
<div className="form-group">
<label htmlFor="fnBinaryCheckEnabled" className="checkbox-label">
<input
id="fnBinaryCheckEnabled"
type="checkbox"
checked={form.fnBinaryCheckEnabled !== false}
onChange={(e) => setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}
/>
Check for the <code>fn</code> CLI binary on PATH
</label>
<small>
When enabled, the dashboard probes for a globally-installed{" "}
<code>fn</code> / <code>fusion</code> CLI by spawning{" "}
<code>&lt;bin&gt; --version</code>. Disable this if your local
dev process is the source of truth and you don&apos;t want any
outdated globally-installed binary executed during the probe.
</small>
</div>
<h4 className="settings-section-heading settings-section-heading--spaced">Updates</h4>
<div className="form-group">
<label htmlFor="updateCheckEnabled" className="checkbox-label">
<input
id="updateCheckEnabled"
type="checkbox"
checked={form.updateCheckEnabled !== false}
onChange={(e) => setForm((f) => ({ ...f, updateCheckEnabled: e.target.checked }))}
/>
Check for updates automatically
</label>
<small>
When enabled, Fusion checks npm for new versions of{" "}
<code>@runfusion/fusion</code> and shows update notices in the CLI and dashboard.
Cadence is governed by the frequency below.
</small>
</div>
<div className="form-group">
<label htmlFor="updateCheckFrequency">Frequency</label>
<select
id="updateCheckFrequency"
value={form.updateCheckFrequency ?? "daily"}
onChange={(e) =>
setForm((f) => ({
...f,
updateCheckFrequency: e.target.value as "manual" | "on-startup" | "daily" | "weekly",
}))
}
disabled={form.updateCheckEnabled === false}
>
<option value="manual">Manual only — never auto-check</option>
<option value="on-startup">On startup — once per server launch</option>
<option value="daily">Daily (recommended)</option>
<option value="weekly">Weekly</option>
</select>
<small>
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.
</small>
</div>
<div className="form-group">
<label htmlFor="autoReloadOnVersionChange" className="checkbox-label">
<input
id="autoReloadOnVersionChange"
type="checkbox"
checked={form.autoReloadOnVersionChange !== false}
onChange={(e) => setForm((f) => ({ ...f, autoReloadOnVersionChange: e.target.checked }))}
/>
Auto-reload dashboard on version change
</label>
<small>
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.
</small>
</div>
</>
);
}
export default GlobalGeneralSection;

View File

@@ -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 --- */}
<h4 className="settings-section-heading">Default Model</h4>
{modelsLoading ? (
<div className="settings-empty-state">{t("settings.models.loadingModels", "Loading available models…")}</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
{t("settings.models.noModels", "No models available. Configure authentication first.")}
</div>
) : (
<>
<div className="form-group">
<label htmlFor="defaultModel">Default Model</label>
<CustomModelDropdown
id="defaultModel"
label="Default Model"
models={availableModels}
value={selectedValue}
onChange={(val) => {
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}
/>
<small>Default AI model used for task execution when no per-task override is set. &quot;Use default&quot; lets the engine choose automatically.</small>
</div>
<div className="form-group">
<label htmlFor="fallbackModel">Fallback Model</label>
<CustomModelDropdown
id="fallbackModel"
label="Fallback Model"
models={availableModels}
value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""}
onChange={(val) => {
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}
/>
<small>Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.</small>
</div>
</>
)}
{(() => {
const selectedModel = availableModels.find(
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
);
if (selectedModel && !selectedModel.reasoning) return null;
return (
<div className="form-group">
<label htmlFor="defaultThinkingLevel">Thinking Effort</label>
<select
id="defaultThinkingLevel"
value={form.defaultThinkingLevel || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
}}
>
<option value="">Default</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</option>
))}
</select>
<small>Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.</small>
</div>
);
})()}
{availableModels.length > 0 && (
<>
<h4 className="settings-section-heading settings-section-heading--spaced">Model Lanes</h4>
<p className="settings-description">
Global baseline models for each AI role. Project settings can override these per-project.
</p>
{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 (
<div className="form-group" key={`global-${lane.laneId}`}>
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
<CustomModelDropdown
id={`global-${lane.laneId}-model`}
label={lane.label}
models={availableModels}
value={value}
onChange={(selected) => {
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}
/>
<small>{lane.helperText}</small>
</div>
);
})}
</>
)}
{/* --- Startup Model Sync --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Startup Model Sync</h4>
<div className="form-group">
<label htmlFor="openrouterModelSync" className="checkbox-label">
<input
id="openrouterModelSync"
type="checkbox"
checked={form.openrouterModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}
/>
Sync OpenRouter model list at startup
</label>
<small>
When enabled, startup fetches the latest available models from the OpenRouter API so
model pickers always include the newest catalog.
</small>
</div>
<div className="form-group">
<label htmlFor="opencodeGoModelSync" className="checkbox-label">
<input
id="opencodeGoModelSync"
type="checkbox"
checked={form.opencodeGoModelSync !== false}
onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}
/>
Sync opencode-go model list at startup
</label>
<small>
When enabled, startup refreshes models through the local <code>opencode models opencode --refresh</code>
flow and publishes them under the opencode-go provider in model pickers.
</small>
</div>
<details>
<summary>OpenRouter advanced</summary>
<div className="form-group">
<label htmlFor="openrouterAppAttributionReferer">OpenRouter HTTP-Referer</label>
<input
id="openrouterAppAttributionReferer"
className="input"
placeholder="https://runfusion.ai"
value={form.openrouterAppAttribution?.referer ?? ""}
onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
referer: e.target.value,
},
}))}
/>
<small>Leave empty to omit this header. Default: https://runfusion.ai.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterAppAttributionTitle">OpenRouter X-Title</label>
<input
id="openrouterAppAttributionTitle"
className="input"
placeholder="Fusion"
value={form.openrouterAppAttribution?.title ?? ""}
onChange={(e) => setForm((f) => ({
...f,
openrouterAppAttribution: {
...(f.openrouterAppAttribution || {}),
title: e.target.value,
},
}))}
/>
<small>Leave empty to omit this header. Default: Fusion.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterModelFiltersSupportedParameters">OpenRouter supported_parameters filter</label>
<input
id="openrouterModelFiltersSupportedParameters"
className="input"
placeholder="tools, structured_outputs"
value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterModelFilters: {
...(f.openrouterModelFilters || {}),
supported_parameters: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
<small>Comma-separated values sent to OpenRouter model sync.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterModelFiltersOutputModalities">OpenRouter output_modalities filter</label>
<input
id="openrouterModelFiltersOutputModalities"
className="input"
placeholder="text"
value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterModelFilters: {
...(f.openrouterModelFilters || {}),
output_modalities: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
<small>Comma-separated values sent to OpenRouter model sync.</small>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesOrder">OpenRouter routing order</label>
<input
id="openrouterProviderPreferencesOrder"
className="input"
placeholder="openai, anthropic"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
order: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesIgnore">OpenRouter routing ignore</label>
<input
id="openrouterProviderPreferencesIgnore"
className="input"
placeholder="provider-name"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
ignore: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesOnly">OpenRouter routing only</label>
<input
id="openrouterProviderPreferencesOnly"
className="input"
placeholder="provider-name"
value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)}
onChange={(e) => {
const parsed = fromCommaSeparatedInput(e.target.value);
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
only: parsed.length > 0 ? parsed : undefined,
},
}));
}}
/>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">OpenRouter allow fallbacks</label>
<select
id="openrouterProviderPreferencesAllowFallbacks"
className="select"
value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
allow_fallbacks: value === "default" ? undefined : value === "allow",
},
}));
}}
>
<option value="default">default</option>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesSort">OpenRouter routing sort</label>
<select
id="openrouterProviderPreferencesSort"
className="select"
value={form.openrouterProviderPreferences?.sort ?? "default"}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
sort: value === "default" ? undefined : value as "price" | "throughput" | "latency",
},
}));
}}
>
<option value="default">default</option>
<option value="price">price</option>
<option value="throughput">throughput</option>
<option value="latency">latency</option>
</select>
</div>
<div className="form-group">
<label htmlFor="openrouterProviderPreferencesRequireParameters" className="checkbox-label">
<input
id="openrouterProviderPreferencesRequireParameters"
type="checkbox"
checked={form.openrouterProviderPreferences?.require_parameters === true}
onChange={(e) => setForm((f) => ({
...f,
openrouterProviderPreferences: {
...(f.openrouterProviderPreferences || {}),
require_parameters: e.target.checked,
},
}))}
/>
Require parameters
</label>
</div>
</details>
</>
);
}
export default GlobalModelsSection;

View File

@@ -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}
<h4 className="settings-section-heading">Node Sync</h4>
<div className="form-group">
<label htmlFor="settingsSyncEnabled" className="checkbox-label">
<input
id="settingsSyncEnabled"
type="checkbox"
checked={form.settingsSyncEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, settingsSyncEnabled: e.target.checked }))}
/>
Enable automatic settings sync
</label>
<small>Automatically synchronize settings between this node and connected remote nodes</small>
</div>
{form.settingsSyncEnabled && (
<>
<div className="form-group">
<label htmlFor="settingsSyncAuth" className="checkbox-label">
<input
id="settingsSyncAuth"
type="checkbox"
checked={form.settingsSyncAuth || false}
onChange={(e) => setForm((f) => ({ ...f, settingsSyncAuth: e.target.checked }))}
/>
Sync model auth credentials
</label>
<small>Include API keys and OAuth tokens in sync operations</small>
</div>
<div className="form-group">
<label htmlFor="settingsSyncInterval">Sync interval</label>
<select
id="settingsSyncInterval"
className="select"
value={form.settingsSyncInterval || 900000}
onChange={(e) =>
setForm((f) => ({ ...f, settingsSyncInterval: parseInt(e.target.value, 10) }))
}
>
<option value={300000}>Every 5 minutes</option>
<option value={900000}>Every 15 minutes</option>
<option value={1800000}>Every 30 minutes</option>
<option value={3600000}>Every 1 hour</option>
</select>
</div>
<div className="form-group">
<label htmlFor="settingsSyncConflictResolution">Conflict resolution</label>
<select
id="settingsSyncConflictResolution"
className="select"
value={form.settingsSyncConflictResolution || "last-write-wins"}
onChange={(e) =>
setForm((f) => ({
...f,
settingsSyncConflictResolution: e.target.value as
| "last-write-wins"
| "always-ask"
| "keep-local"
| "keep-remote",
}))
}
>
<option value="last-write-wins">Last write wins</option>
<option value="always-ask">Always ask</option>
<option value="keep-local">Keep local</option>
<option value="keep-remote">Keep remote</option>
</select>
</div>
</>
)}
{/* KTD-8: workflow settings are not yet part of the cross-node sync
channel. Non-dismissible, informational only, no action affordance. */}
<p className="settings-sync-workflow-note text-muted" role="note">
{t(
"settings.nodeSync.workflowSettingsNotSynced",
"Workflow settings are not synced across nodes yet.",
)}
</p>
</>
);
}
export default NodeSyncSection;

View File

@@ -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<string, boolean>;
testNotificationResult: Record<string, { status: "success" | "error"; message: string }>;
onTestProviderNotification: (provider: TestNotificationProvider) => void;
}
export function NotificationsSection({
scopeBanner,
form,
setForm,
testNotificationLoading,
testNotificationResult,
onTestProviderNotification,
}: NotificationsSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">Notifications</h4>
<div className="notification-provider-card">
<div className="form-group">
<label htmlFor="failureNotificationMode">Failure notification mode</label>
<select
id="failureNotificationMode"
value={form.failureNotificationMode ?? "sticky-only"}
onChange={(e) => {
const value = e.target.value as "sticky-only" | "all" | "terminal-only";
setForm((f) => ({ ...f, failureNotificationMode: value }));
}}
>
<option value="sticky-only">Sticky failures only (default)</option>
<option value="terminal-only">Terminal failures only (suppress auto-retried)</option>
<option value="all">All failures (legacy)</option>
</select>
<small>Sticky-only suppresses recovered failures; terminal-only waits for paused/in-review failed tasks; all restores legacy alerts.</small>
</div>
<div className="form-group">
<label htmlFor="failureNotificationDelayMs">Failure notification delay (ms)</label>
<input
id="failureNotificationDelayMs"
type="number"
min={0}
step={1000}
disabled={(form.failureNotificationMode ?? "sticky-only") === "all"}
value={form.failureNotificationDelayMs ?? 30000}
onChange={(e) => {
const parsed = Number(e.target.value);
setForm((f) => ({
...f,
failureNotificationDelayMs: Number.isFinite(parsed) && parsed >= 0 ? parsed : 0,
}));
}}
/>
<small>
How long a failure must persist before a push notification is sent. 0 = notify immediately.
</small>
</div>
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>ntfy</strong>
<label htmlFor="ntfyEnabled" className="checkbox-label">
<input
id="ntfyEnabled"
type="checkbox"
checked={form.ntfyEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))}
/>
Enable
</label>
</div>
{form.ntfyEnabled && (
<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="ntfyTopic">ntfy Topic</label>
<input
id="ntfyTopic"
type="text"
placeholder="my-topic-name"
value={form.ntfyTopic || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}
/>
<small>
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="settings-inline-link"
>
Learn more about ntfy.sh
</a>
</small>
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
<small className="field-error">
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
</small>
)}
<details className="ntfy-advanced-disclosure">
<summary>Advanced</summary>
<div className="ntfy-advanced-content">
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
<input
id="ntfyBaseUrl"
type="url"
placeholder="https://ntfy.sh"
value={form.ntfyBaseUrl || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
}}
/>
<small>
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
</small>
<label htmlFor="ntfyAccessToken">Access token (optional)</label>
<input
id="ntfyAccessToken"
type="password"
autoComplete="off"
placeholder="tk_..."
value={form.ntfyAccessToken || ""}
onChange={(e) => {
const value = e.target.value;
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
}}
/>
<small>
Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests.
</small>
</div>
</details>
</div>
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const checked = form.ntfyEvents?.includes(event) ?? true;
return (
<div key={`ntfy-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== event);
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>
</div>
<div className="form-group">
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
<input
id="ntfyDashboardHost"
type="text"
placeholder="http://localhost:3000"
value={form.ntfyDashboardHost || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
}}
/>
<small>
Base URL for deep links in notifications. When set, clicking a notification
opens the dashboard directly to the task.
</small>
{form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && (
<small className="field-error">
Must be a valid URL starting with http:// or https://
</small>
)}
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testNotification", "Test notification")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy-message")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy-message"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testMessageInbox", "Test message inbox")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("ntfy-room")}
disabled={
testNotificationLoading["ntfy"] ||
testNotificationLoading["ntfy-message"] ||
testNotificationLoading["ntfy-room"] ||
!form.ntfyEnabled ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading["ntfy-room"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testRoomReply", "Test room reply")}
</button>
</div>
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
<div className="notification-test-feedback" aria-live="polite">
{testNotificationResult["ntfy"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
General: {testNotificationResult["ntfy"].message}
</small>
)}
{testNotificationResult["ntfy-message"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-message"].status}`}>
Message inbox: {testNotificationResult["ntfy-message"].message}
</small>
)}
{testNotificationResult["ntfy-room"] && (
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>
Room reply: {testNotificationResult["ntfy-room"].message}
</small>
)}
</div>
)}
</div>
)}
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>Webhook</strong>
<label htmlFor="webhookEnabled" className="checkbox-label">
<input
id="webhookEnabled"
type="checkbox"
checked={form.webhookEnabled || false}
onChange={(e) => setForm((f) => ({ ...f, webhookEnabled: e.target.checked }))}
/>
Webhook notifications
</label>
</div>
{form.webhookEnabled && (
<div className="notification-provider-body">
<div className="form-group">
<label htmlFor="webhookUrl">Webhook URL</label>
<input
id="webhookUrl"
type="text"
placeholder="https://hooks.example.com/..."
value={form.webhookUrl || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, webhookUrl: val || undefined }));
}}
/>
</div>
<div className="form-group">
<label htmlFor="webhookFormat">Format</label>
<select
id="webhookFormat"
value={form.webhookFormat || "generic"}
onChange={(e) => {
const val = e.target.value as "slack" | "discord" | "generic";
setForm((f) => ({ ...f, webhookFormat: val }));
}}
>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic</option>
</select>
</div>
<div className="form-group">
<label>Notify on events</label>
<div className="ntfy-events-list">
{NOTIFICATION_EVENT_OPTIONS.map(({ event, label, description }) => {
const currentEvents = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const checked = currentEvents.includes(event);
return (
<div key={`webhook-${event}`}>
<label className="checkbox-label">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const current = form.webhookEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes(event) ? current : [...current, event])
: current.filter((ev) => ev !== event);
setForm((f) => ({ ...f, webhookEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
{label}
</label>
<small>{description}</small>
</div>
);
})}
</div>
</div>
<div className="notification-provider-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onTestProviderNotification("webhook")}
disabled={testNotificationLoading["webhook"] || !form.webhookUrl}
>
{testNotificationLoading["webhook"] ? t("settings.notifications.sending", "Sending…") : t("settings.notifications.testNotification", "Test notification")}
</button>
</div>
{testNotificationResult["webhook"] && (
<div className="notification-test-feedback" aria-live="polite">
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["webhook"].status}`}>
{testNotificationResult["webhook"].message}
</small>
</div>
)}
</div>
)}
</div>
</>
);
}
export default NotificationsSection;

View File

@@ -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<void>;
runRemoteAction: (label: string, action: () => Promise<void>) => Promise<void>;
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<string, unknown>;
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<RemoteSettings> => {
const formState = form as Record<string, unknown>;
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}
<h4 className="settings-section-heading">Remote Access</h4>
<div className={`remote-status-bar remote-status-bar--${statusColor}`}>
<span className={`remote-status-dot remote-status-dot--${statusColor}`} />
<strong>{tunnelState}</strong>
{remoteStatus?.provider && <span> · {remoteStatus.provider}</span>}
{remoteStatus?.url && <code className="remote-status-url">{remoteStatus.url}</code>}
{remoteStatus?.lastError && <span className="field-error">{remoteStatus.lastError}</span>}
</div>
{tunnelState === "stopped" && externalTunnel && (
<div className="remote-external-tunnel-panel" role="status">
<div className="remote-external-tunnel-header">
<Globe aria-hidden="true" />
<strong>External {externalTunnel.provider} tunnel detected</strong>
</div>
{externalTunnel.url && <code className="settings-url-output">{externalTunnel.url}</code>}
{tunnelShareLink?.qrSvg && (
<div className="remote-external-tunnel-qr">
<small>Scan to open:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="External tunnel QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
)}
{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 (
<div className="remote-share-block">
{tailnetUrl && (
<div className="remote-share-row">
<small>Tailnet URL:</small>
<code className="settings-url-output">{tailnetUrl}</code>
</div>
)}
{accessCode && (
<div className="remote-share-row">
<small>Remote access code:</small>
<code className="settings-url-output">{accessCode}</code>
</div>
)}
{tunnelShareLink?.qrSvg && (
<div className="remote-share-row">
<small>Scan to connect:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="Remote access QR code"
className="settings-qr-preview-image"
/>
</div>
)}
</div>
);
})()}
<div className="form-group">
<div className="remote-provider-selector" role="radiogroup" aria-label="Remote provider">
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="tailscale" checked={activeProvider === "tailscale"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "tailscale" } as SettingsFormState))} />
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-tailscale" aria-hidden="true"><Globe size={16} /></span>
<span>Tailscale</span>
</span>
</span>
</label>
<label className="remote-provider-option">
<input type="radio" name="remoteProvider" value="cloudflare" checked={activeProvider === "cloudflare"} onChange={() => setForm((f) => ({ ...f, remoteActiveProvider: "cloudflare" } as SettingsFormState))} />
<span>
<span className="remote-provider-option-content">
<span data-testid="remote-provider-icon-cloudflare" aria-hidden="true" className="remote-provider-option-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="remote-cloudflare-option-icon">
<path d="M7 16.5h10.8a2.9 2.9 0 0 0 .3-5.8 4.9 4.9 0 0 0-9.3-1.6A3.6 3.6 0 0 0 7 16.5m-1.9 0h3.2a2.5 2.5 0 0 0 .2-5 3.4 3.4 0 0 0-3.4 3.4c0 .6 0 1 .2 1.6" fill="var(--provider-cloudflare)" />
</svg>
</span>
<span>Cloudflare</span>
</span>
</span>
</label>
</div>
{!activeProvider && <small>Select a provider above to configure remote access.</small>}
</div>
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (
<div className="remote-cli-detection remote-cli-detection--available" role="status">
<CheckCircle aria-hidden="true" />
<span>cloudflared is installed</span>
</div>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (
<div className="remote-cli-detection remote-cli-detection--missing" role="status">
<AlertTriangle aria-hidden="true" />
<div className="remote-cli-detection-content">
<span>cloudflared is not installed</span>
<button
type="button"
className="btn btn-sm"
disabled={cloudflaredInstalling || remoteBusyAction !== null}
onClick={() => void handleInstallCloudflared()}
>
{cloudflaredInstalling ? "Installing…" : "Install cloudflared"}
</button>
{cloudflaredInstallError && <small className="remote-cli-install-error">{cloudflaredInstallError}</small>}
<small className="remote-cli-manual">Manual install: <code>{cloudflaredManualInstallCommand()}</code></small>
{cloudflaredMacFallbackCommand()
? <small className="remote-cli-manual">If Homebrew is unavailable: <code>{cloudflaredMacFallbackCommand()}</code></small>
: null}
</div>
</div>
)}
{activeProvider && (
<div className="form-group remote-provider-settings">
{activeProvider === "tailscale" ? (
<>
<small>Tailscale Funnel will expose this dashboard on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname or port configuration needed.</small>
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
<input id="remoteTailscaleAcceptRoutes" type="checkbox" checked={Boolean(remoteForm.remoteTailscaleAcceptRoutes)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: e.target.checked } as SettingsFormState))} />
Accept routes
</label>
</>
) : (
<>
<small>
{(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."}
</small>
<details
className="remote-cf-advanced-details"
open={!(remoteForm.remoteCloudflareQuickTunnel ?? true)}
onToggle={(event) => {
const detailsOpen = event.currentTarget.open;
setForm((f) => {
const currentQuickTunnel = Boolean((f as Record<string, unknown>).remoteCloudflareQuickTunnel ?? true);
const nextQuickTunnel = !detailsOpen;
if (currentQuickTunnel === nextQuickTunnel) {
return f;
}
return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState;
});
}}
>
<summary>Advanced (Named Tunnel)</summary>
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (
<div className="remote-cf-advanced-fields">
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
<input id="remoteCloudflareTunnelName" type="text" placeholder="Tunnel name" value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
<input id="remoteCloudflareTunnelToken" type="password" placeholder="Tunnel token" value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
<input id="remoteCloudflareIngressUrl" type="text" placeholder="https://your-domain.example" value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} />
</div>
) : null}
</details>
</>
)}
</div>
)}
<div className="form-group remote-tunnel-actions">
{tunnelState === "running" || tunnelState === "starting" ? (
<button type="button" className="btn btn-danger" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("stop", async () => {
await stopRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStopped", "Remote tunnel stopped"), "success");
})}>
{remoteBusyAction === "stop" ? t("settings.remote.stopping", "Stopping…") : t("settings.remote.stopTunnel", "Stop Tunnel")}
</button>
) : (
<>
{externalTunnel ? (
<div className="remote-external-tunnel-actions">
<button type="button" className="btn" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start fresh", async () => {
if (!activeProvider) return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await killExternalTunnel(projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelRestarted", "Remote tunnel restarted"), "success");
})}>
{remoteBusyAction === "start fresh" ? t("settings.remote.restarting", "Restarting…") : t("settings.remote.startFresh", "Start Fresh")}
</button>
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("use existing", async () => {
if (!activeProvider) return;
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
{remoteBusyAction === "use existing" ? t("settings.remote.starting", "Starting…") : t("settings.remote.useExisting", "Use Existing")}
</button>
</div>
) : (
<button type="button" className="btn btn-primary" disabled={!activeProvider || remoteBusyAction !== null} onClick={() => void runRemoteAction("start", async () => {
if (!activeProvider) return;
// Server overrides remoteTailscaleTargetPort with
// req.socket.localPort when starting the tunnel; the value sent
// here is only a fallback if that override doesn't fire.
await updateRemoteSettings(buildSavePayload(activeProvider), projectId);
await startRemoteTunnel(projectId);
addToast(t("settings.remote.tunnelStarted", "Remote tunnel started"), "success");
})}>
{remoteBusyAction === "start" ? t("settings.remote.starting", "Starting…") : t("settings.remote.startTunnel", "Start Tunnel")}
</button>
)}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? (
<small className="field-error">cloudflared must be installed to start the tunnel</small>
) : null}
</>
)}
</div>
<details className="remote-advanced-details">
<summary>Advanced Settings</summary>
<div className="form-group">
<label htmlFor="remoteShortLivedEnabled" className="checkbox-label">
<input id="remoteShortLivedEnabled" type="checkbox" checked={Boolean(remoteForm.remoteShortLivedEnabled)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))} />
Enable short-lived tokens
</label>
<label htmlFor="remoteShortLivedTtlMs">Short-lived TTL (ms)</label>
<input id="remoteShortLivedTtlMs" type="number" min={60000} max={86400000} value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)} onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))} />
{remoteShortLivedToken && <small>Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)</small>}
</div>
<div className="form-group">
<label htmlFor="remoteRememberLastRunning" className="checkbox-label">
<input id="remoteRememberLastRunning" type="checkbox" checked={Boolean(remoteForm.remoteRememberLastRunning)} onChange={(e) => setForm((f) => ({ ...f, remoteRememberLastRunning: e.target.checked } as SettingsFormState))} />
Remember last running state
</label>
<small>Automatically restore tunnel on startup if it was running when last stopped.</small>
</div>
<div className="form-group">
<label>Auth Links</label>
<div className="settings-button-row">
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("regenerate persistent token", async () => {
await regenerateRemotePersistentToken(projectId);
addToast(t("settings.remote.persistentTokenRegenerated", "Persistent token regenerated"), "success");
})}>Regenerate persistent token</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("generate short-lived token", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const generated = await generateShortLivedRemoteToken(ttlMs, projectId);
setRemoteShortLivedToken(generated);
addToast(t("settings.remote.shortLivedTokenGenerated", "Short-lived token generated"), "success");
})}>Generate short-lived token</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("fetch remote url", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const nextUrl = await fetchRemoteUrl({ projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview(nextUrl);
setRemoteQrSvg(null);
})}>Show URL</button>
<button type="button" className="btn btn-sm" disabled={remoteBusyAction !== null} onClick={() => void runRemoteAction("generate QR", async () => {
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
const qr = await fetchRemoteQr("image/svg", { projectId, tokenType: remoteAuthLinkTokenType, ttlMs: remoteAuthLinkTokenType === "short-lived" ? ttlMs : undefined });
setRemoteUrlPreview({ url: qr.url, expiresAt: qr.expiresAt, tokenType: qr.tokenType });
setRemoteQrSvg(qr.data ?? null);
})}>Generate QR</button>
</div>
<label htmlFor="remoteAuthLinkTokenType">Auth link token type</label>
<select id="remoteAuthLinkTokenType" value={remoteAuthLinkTokenType} onChange={(e) => setRemoteAuthLinkTokenType(e.target.value as "persistent" | "short-lived")}>
<option value="persistent">Persistent token</option>
<option value="short-lived">Short-lived token</option>
</select>
<small>
URL and QR generation use the selected token type.
{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""}
</small>
{remoteUrlPreview?.url && (
<>
<small>Authenticated URL:<code className="settings-url-output">{remoteUrlPreview.url}</code></small>
<small>
Token type: <strong>{remoteUrlPreview.tokenType}</strong>
{remoteUrlPreview.expiresAt ? ` · Expires at ${new Date(remoteUrlPreview.expiresAt).toLocaleString()}` : " · No expiry"}
</small>
</>
)}
{remoteQrSvg && (
<div className="settings-qr-preview" aria-live="polite">
<p className="settings-qr-preview-label">Scan this QR code on your phone</p>
<div className="settings-qr-preview-image-wrap">
<img src={`data:image/svg+xml;utf8,${encodeURIComponent(remoteQrSvg)}`} alt="Remote access QR code" className="settings-qr-preview-image" />
</div>
<details>
<summary>QR SVG markup</summary>
<pre className="settings-raw-output">{remoteQrSvg}</pre>
</details>
</div>
)}
</div>
</details>
</>
);
}
export default RemoteSection;

View File

@@ -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}
<h4 className="settings-section-heading">Research Defaults</h4>
<div className="form-group settings-research-provider-group">
<label htmlFor="research-global-provider-builtin" className="checkbox-label">
<input
id="research-global-provider-builtin"
type="radio"
name="research-global-search-provider"
checked={!externalProvider}
onChange={() => setSearchProvider("builtin")}
/>
Built-in (uses agent web tools)
</label>
<small>
Searches and fetches use the agent's native WebSearch/WebFetch tools. No API key required.
</small>
<details className="settings-option-details settings-research-provider-advanced-details">
<summary>Advanced — external search providers</summary>
<div className="settings-research-provider-advanced-body">
<div className="form-group">
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
<select
id="research-global-search-provider-advanced"
className="input"
value={externalProvider ? resolvedProvider : "searxng"}
onChange={(event) =>
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
}
>
<option value="searxng">SearXNG</option>
<option value="brave">Brave</option>
<option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option>
</select>
</div>
<div className="form-group">
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
<input
id="research-global-searxng-url"
className="input"
value={form.researchGlobalSearxngUrl ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalSearxngUrl: event.target.value || undefined,
}))
}
placeholder="https://searx.example.com"
/>
</div>
<div className="form-group">
<label htmlFor="research-global-google-cx">Google Search CX</label>
<input
id="research-global-google-cx"
className="input"
value={form.researchGlobalGoogleSearchCx ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGoogleSearchCx: event.target.value || undefined,
}))
}
placeholder="custom-search-engine-id"
/>
</div>
<div className="settings-empty-state settings-research-empty-state" role="note">
Configure Brave, Tavily, and Google API keys in Authentication.
<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>
Open Authentication Settings
</button>
</div>
</div>
</details>
</div>
<div className="form-group">
<div className="settings-research-limits-grid">
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-concurrent">Default Max Concurrent Runs</label>
<input
id="research-global-max-concurrent"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxConcurrentRuns ?? 3}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxConcurrentRuns: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-sources">Default Max Sources Per Run</label>
<input
id="research-global-max-sources"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxSourcesPerRun ?? 20}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
researchGlobalDefaults: {
...(current.researchGlobalDefaults ?? {}),
maxSourcesPerRun: event.target.value === "" ? undefined : Number(event.target.value),
},
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-default-timeout">Default Max Duration (ms)</label>
<input
id="research-global-default-timeout"
className="input"
type="number"
min={1000}
value={form.researchGlobalDefaultTimeout ?? 300000}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalDefaultTimeout: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-fetch-timeout">Request Timeout (ms)</label>
<input
id="research-global-fetch-timeout"
className="input"
type="number"
min={1000}
value={form.researchGlobalFetchTimeoutMs ?? 30000}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalFetchTimeoutMs: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
<div className="settings-research-limit-field">
<label htmlFor="research-global-max-synthesis-rounds">Max Synthesis Rounds</label>
<input
id="research-global-max-synthesis-rounds"
className="input"
type="number"
min={1}
value={form.researchGlobalMaxSynthesisRounds ?? 2}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalMaxSynthesisRounds: event.target.value === "" ? undefined : Number(event.target.value),
}))
}
/>
</div>
</div>
</div>
<div className="form-group">
<label>Enabled Sources</label>
<label htmlFor="research-global-source-webSearch" className="checkbox-label settings-research-source-locked">
<input id="research-global-source-webSearch" type="checkbox" checked disabled readOnly />
Web Search <span className="settings-muted">Always on</span>
</label>
<div className="settings-research-source-grid">
<label htmlFor="research-global-source-github" className="checkbox-label">
<input
id="research-global-source-github"
type="checkbox"
checked={form.researchGlobalGitHubEnabled ?? false}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGitHubEnabled: event.target.checked,
}))
}
/>
GitHub
</label>
<label htmlFor="research-global-source-local-docs" className="checkbox-label">
<input
id="research-global-source-local-docs"
type="checkbox"
checked={form.researchGlobalLocalDocsEnabled ?? true}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalLocalDocsEnabled: event.target.checked,
}))
}
/>
Local Docs
</label>
</div>
</div>
{hasMissingResearchCredential && (
<div className="settings-empty-state" role="alert">
Missing credentials for the selected research provider.
<button type="button" className="btn btn-sm" onClick={() => onNavigateToSection("authentication")}>
Open Authentication
</button>
</div>
)}
</>
);
}
export default ResearchGlobalSection;

View File

@@ -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 (
<>
<h4 className="settings-section-heading">Hermes Runtime</h4>
<HermesRuntimeCard />
</>
);
}
export function OpenClawRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">OpenClaw Runtime</h4>
<OpenClawRuntimeCard />
</>
);
}
export function PaperclipRuntimeSection() {
return (
<>
<h4 className="settings-section-heading">Paperclip Runtime</h4>
<PaperclipRuntimeCard />
</>
);
}

View File

@@ -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;
}