feat(dashboard): remove moved settings from SettingsModal with workflow-editor redirect stubs, target-IA regroup, secrets/prompts extraction

This commit is contained in:
gsxdsm
2026-06-05 00:46:59 -07:00
parent 8c9702e909
commit 3a33604159
12 changed files with 394 additions and 408 deletions

View File

@@ -0,0 +1,65 @@
/**
* Moved-key removal sweep (U9 / KTD-5, R10).
*
* After the hard-move (U4), every key in `MOVED_SETTINGS_KEYS` lives exclusively
* as a workflow setting value. None of them may be renderable or savable from the
* Settings modal anymore. A DOM sweep of every section is expensive and flaky, so
* we use the consistency-test pattern instead: assert the modal's source (and its
* extracted Project section components) never bind a moved key to a form
* control — i.e. no `form.<movedKey>` read and no `<movedKey>:` write inside a
* `setForm`/`setPresetDraft`-shaped object literal.
*
* The intentional exceptions are the redirect stubs and the `MODEL_LANES`
* descriptor table, which only NAMES the keys (as `projectProviderKey` /
* `projectModelKey` string literals) so the surviving "default" lane can be
* rendered — those are not form bindings. We therefore match the precise binding
* shapes (`form.<key>` and `<key>:`) and explicitly allow descriptor mentions.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { MOVED_SETTINGS_KEYS } from "@fusion/core";
const here = dirname(fileURLToPath(import.meta.url));
const componentsDir = join(here, "..", "components");
/** Files that compose the modal's editable surface (shell + Project sections). */
const SURFACE_FILES = [
"SettingsModal.tsx",
];
/**
* Keys that are also legitimately referenced as nested object properties on
* non-settings shapes (e.g. `ModelPreset.validatorProvider`, a preset draft
* field that is NOT the top-level project setting). For these we only forbid the
* `form.<key>` read shape, which unambiguously binds the project setting.
*/
const PRESET_NESTED_KEYS = new Set([
"validatorProvider",
"validatorModelId",
]);
describe("SettingsModal moved-key removal sweep", () => {
for (const file of SURFACE_FILES) {
const source = readFileSync(join(componentsDir, file), "utf8");
for (const key of MOVED_SETTINGS_KEYS) {
it(`${file} does not read form.${key}`, () => {
// The form-binding read shape: `form.<movedKey>` (word boundary).
const formRead = new RegExp(`\\bform\\.${key}\\b`);
expect(source).not.toMatch(formRead);
});
if (!PRESET_NESTED_KEYS.has(key)) {
it(`${file} does not write ${key} into a form patch`, () => {
// The form-write shape inside a setForm object literal: `<key>:`.
// Allowed: descriptor table entries (`projectProviderKey: "<key>"`),
// which quote the key as a value, never as an object KEY.
const formWrite = new RegExp(`(^|[\\s{,])${key}\\s*:`, "m");
expect(source).not.toMatch(formWrite);
});
}
}
}
});

View File

@@ -16,8 +16,18 @@ 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 { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
import { PromptsSection } from "../components/settings/sections/PromptsSection";
import { SecretsSection } from "../components/settings/sections/SecretsSection";
import type { SettingsFormState } from "../components/settings/sections/context";
vi.mock("../components/AgentPromptsManager", () => ({
AgentPromptsManager: () => <div data-testid="agent-prompts-manager" />,
}));
vi.mock("../components/SecretsView", () => ({
SecretsView: () => <div data-testid="secrets-view" />,
}));
expect.extend(jestDomMatchers);
afterEach(() => cleanup());
@@ -99,6 +109,42 @@ describe("NotificationsSection", () => {
});
});
describe("SecretsSection", () => {
it("renders the scope banner, title, and the SecretsView card", () => {
render(
<SecretsSection scopeBanner={<div data-testid="scope-banner" />} addToast={vi.fn()} />,
);
expect(screen.getByTestId("scope-banner")).toBeInTheDocument();
expect(screen.getByText("Secrets")).toBeInTheDocument();
expect(screen.getByTestId("secrets-view")).toBeInTheDocument();
});
});
describe("PromptsSection", () => {
it("renders the title and mounts AgentPromptsManager", () => {
render(
<PromptsSection scopeBanner={null} form={emptyForm} setForm={vi.fn()} />,
);
expect(screen.getByText("Prompts")).toBeInTheDocument();
expect(screen.getByTestId("agent-prompts-manager")).toBeInTheDocument();
});
});
describe("MovedSettingsStub", () => {
it("renders the message and fires the open-workflow-settings callback", () => {
const onOpen = vi.fn();
render(<MovedSettingsStub message="Step execution moved" onOpenWorkflowSettings={onOpen} />);
expect(screen.getByText("Step execution moved")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open workflow settings" }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("disables the action when no handler is wired", () => {
render(<MovedSettingsStub message="Moved" />);
expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled();
});
});
describe("ExperimentalSection", () => {
const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" };
const legacyAliases: Record<string, string> = { devServer: "devServerView" };

View File

@@ -242,6 +242,10 @@ export function AppModals({
onDashboardFontScaleChange={settings.setDashboardFontScalePct}
onReopenOnboarding={onReopenOnboarding}
onOpenApprovals={onOpenApprovals}
onOpenWorkflowSettings={() => {
handleSettingsClose();
modalManager.openWorkflowEditor("settings");
}}
/>
</Suspense>
</ModalErrorBoundary>
@@ -394,6 +398,7 @@ export function AppModals({
onClose={modalManager.closeWorkflowEditor}
addToast={addToast}
projectId={projectId}
initialPanel={modalManager.workflowEditorInitialPanel}
/>
</Suspense>
</ModalErrorBoundary>

View File

@@ -3,13 +3,10 @@ import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-reac
import {
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
getErrorMessage,
resolvePlanningSettingsModel,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
normalizeMergeIntegrationWorktreeMode,
normalizeMergeAdvanceAutoSyncMode,
} from "@fusion/core";
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, AgentPromptsConfig } from "@fusion/core";
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
import { splitSettingsSave } from "./settings/save-split";
@@ -27,6 +24,9 @@ import {
OpenClawRuntimeSection,
PaperclipRuntimeSection,
} from "./settings/sections/RuntimesSections";
import { MovedSettingsStub } from "./settings/sections/MovedSettingsStub";
import { SecretsSection } from "./settings/sections/SecretsSection";
import { PromptsSection } from "./settings/sections/PromptsSection";
import { ProjectDefaultWorkflowField } from "./WorkflowSelector";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -42,11 +42,9 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { ProviderIcon } from "./ProviderIcon";
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
import { AgentProvisioningPolicyEditor } from "./AgentProvisioningPolicyEditor";
import { SecretsView } from "./SecretsView";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
@@ -255,8 +253,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" },
{ id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" },
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
{ id: "remote", label: "Remote Access & Node Sync", labelKey: "settings.nav.remote", scope: "global" },
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" },
// Runtimes group (plugin runtimes with their own settings)
{ id: "__runtimes_header", label: "Runtimes", labelKey: "settings.nav.runtimesHeader", scope: undefined, isGroupHeader: true },
@@ -267,19 +265,19 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
// Project group (specific to this project)
{ id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true },
{ id: "general", label: "Project General", labelKey: "settings.nav.projectGeneral", scope: "project" },
{ id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" },
{ id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" },
{ id: "scheduling", label: "Scheduling", labelKey: "settings.nav.scheduling", scope: "project" },
{ id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project" },
{ id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project" },
{ id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project" },
{ id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project" },
{ id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project" },
{ id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project" },
{ id: "commands", label: "Commands", labelKey: "settings.nav.commands", scope: "project" },
{ id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project" },
{ id: "agent-permissions", label: "Agent Permissions", labelKey: "settings.nav.agentPermissions", scope: "project" },
{ id: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project" },
{ id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project" },
{ id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" },
{ id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" },
{ id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project" },
{ id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" },
{ id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" },
{ id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" },
{ id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" },
{ id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project" },
];
@@ -378,6 +376,12 @@ interface SettingsModalProps {
onReopenOnboarding?: () => void;
/** Optional callback to open approvals/mailbox view. */
onOpenApprovals?: (approvalId?: string) => void;
/**
* Closes this modal and opens the workflow node editor with its Settings panel
* pre-selected for the project's default workflow. Used by the moved-settings
* redirect stubs (U9 / KTD-5, R10). Optional so the modal renders standalone.
*/
onOpenWorkflowSettings?: () => void;
}
export function SettingsModal({
@@ -393,6 +397,7 @@ export function SettingsModal({
onDashboardFontScaleChange,
onReopenOnboarding,
onOpenApprovals,
onOpenWorkflowSettings,
}: SettingsModalProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
@@ -2242,20 +2247,6 @@ export function SettingsModal({
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast} />
<small>New tasks inherit this custom workflow's steps (overridable per task)</small>
</div>
<div className="form-group">
<label htmlFor="requirePlanApproval" className="checkbox-label">
<input
id="requirePlanApproval"
type="checkbox"
checked={form.requirePlanApproval || false}
onChange={(e) =>
setForm((f) => ({ ...f, requirePlanApproval: e.target.checked }))
}
/>
Require plan approval
</label>
<small>When enabled, AI-generated task specifications require manual approval before moving to Todo</small>
</div>
<div className="form-group">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<input
@@ -2538,34 +2529,19 @@ export function SettingsModal({
);
case "secrets":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Secrets</h4>
<SecretsView addToast={addToast} />
</>
);
return <SecretsSection scopeBanner={renderScopeBanner()} addToast={addToast} />;
case "project-models": {
const presets = form.modelPresets || [];
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
// Filter model lanes to show in project scope.
// The "summarization" lane is intentionally excluded here — it has a
// dedicated picker further down ("AI Title and Git Commit Message
// Summarization") so the project tab doesn't surface the same model
// setting twice.
const projectModelLanes = MODEL_LANES.filter(
(lane) =>
lane.laneId === "default"
|| lane.laneId === "execution"
|| lane.laneId === "planning"
|| lane.laneId === "validator",
);
const resolvedPlanningModel = resolvePlanningSettingsModel(form);
const resolvedDefaultModel = resolveProjectDefaultModel(form);
const resolvedTitleSummarizerModel = resolveTitleSummarizerSettingsModel(form);
// Only the project DEFAULT model lane survives in this modal. The
// per-phase execution/planning/validator lanes, their fallbacks, and the
// title-summarizer lane were hard-moved (U4) onto the workflow settings
// mechanism — they are no longer project settings keys and must never be
// renderable or savable here (redirect stub below).
const projectModelLanes = MODEL_LANES.filter((lane) => lane.laneId === "default");
const getProjectLaneLabel = (lane: ModelLane) => lane.laneId === "default" ? "Project Default Model" : lane.label;
const getProjectLaneHelperText = (lane: ModelLane) =>
lane.laneId === "default"
@@ -2674,72 +2650,15 @@ export function SettingsModal({
</>
)}
{/* --- Fallback Models --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Fallback Models</h4>
{modelsLoading ? (
<div className="settings-empty-state">Loading available models…</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
No models available.
</div>
) : (
<>
<div className="form-group">
<label htmlFor="planningFallbackModel">Planning Fallback Model</label>
<CustomModelDropdown
id="planningFallbackModel"
label="Planning Fallback Model"
models={availableModels}
value={form.planningFallbackProvider && form.planningFallbackModelId ? `${form.planningFallbackProvider}/${form.planningFallbackModelId}` : ""}
onChange={(val) => {
if (!val) {
setForm((f) => ({ ...f, planningFallbackProvider: undefined, planningFallbackModelId: undefined }));
} else {
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
planningFallbackProvider: val.slice(0, slashIdx),
planningFallbackModelId: val.slice(slashIdx + 1),
}));
}
}}
placeholder="Use global fallback"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
<small>Used if the planning model fails due to rate limits or provider overload. Defaults to the global fallback model.</small>
</div>
<div className="form-group">
<label htmlFor="validatorFallbackModel">Reviewer Fallback Model</label>
<CustomModelDropdown
id="validatorFallbackModel"
label="Reviewer Fallback Model"
models={availableModels}
value={form.validatorFallbackProvider && form.validatorFallbackModelId ? `${form.validatorFallbackProvider}/${form.validatorFallbackModelId}` : ""}
onChange={(val) => {
if (!val) {
setForm((f) => ({ ...f, validatorFallbackProvider: undefined, validatorFallbackModelId: undefined }));
} else {
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
validatorFallbackProvider: val.slice(0, slashIdx),
validatorFallbackModelId: val.slice(slashIdx + 1),
}));
}
}}
placeholder="Use global fallback"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
<small>Used if the reviewer model fails due to rate limits or provider overload. Defaults to the global fallback model.</small>
</div>
</>
)}
{/* --- Per-phase model lanes (MOVED to workflow settings) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Per-phase model lanes</h4>
<MovedSettingsStub
message={t(
"settings.movedStub.modelLanes",
"Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
{/* --- Model Presets --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>
@@ -2989,99 +2908,12 @@ export function SettingsModal({
</div>
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (
<>
<div className="form-group">
<label>Title, commit message, and GitHub tracking issue summarization model</label>
{modelsLoading ? (
<small>Loading available models...</small>
) : availableModels.length === 0 ? (
<small>No models available. Configure authentication first.</small>
) : (
<CustomModelDropdown
id="titleSummarizerModel"
label="Title, commit message, and GitHub tracking issue summarization model"
models={availableModels}
value={
form.titleSummarizerProvider && form.titleSummarizerModelId
? `${form.titleSummarizerProvider}/${form.titleSummarizerModelId}`
: ""
}
onChange={(val) => {
if (!val) {
setForm((f) => ({
...f,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
}));
return;
}
const slashIdx = val.indexOf("/");
setForm((f) => ({
...f,
titleSummarizerProvider: val.slice(0, slashIdx),
titleSummarizerModelId: val.slice(slashIdx + 1),
}));
}}
placeholder="Use fallback model"
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
<small>
Also used to summarize task descriptions into GitHub tracking issue titles when a task has no title yet.
</small>
<small>
{form.titleSummarizerProvider && form.titleSummarizerModelId
? "Using explicitly configured model"
: resolvedTitleSummarizerModel.provider && resolvedTitleSummarizerModel.modelId
? resolvedTitleSummarizerModel.provider === resolvedPlanningModel.provider
&& resolvedTitleSummarizerModel.modelId === resolvedPlanningModel.modelId
? "(using planning model)"
: resolvedTitleSummarizerModel.provider === resolvedDefaultModel.provider
&& resolvedTitleSummarizerModel.modelId === resolvedDefaultModel.modelId
? form.defaultProviderOverride && form.defaultModelIdOverride
? "(using project default model)"
: "(using global default model)"
: "(using global summarization model)"
: "(using automatic model selection)"}
</small>
</div>
<div className="form-group">
<div className="modal-actions settings-summarization-actions">
<button
type="button"
className="btn btn-sm"
onClick={() =>
setForm((f) => ({
...f,
titleSummarizerProvider: resolvedPlanningModel.provider,
titleSummarizerModelId: resolvedPlanningModel.modelId,
}))
}
disabled={!resolvedPlanningModel.provider || !resolvedPlanningModel.modelId}
>
Use planning model
</button>
<button
type="button"
className="btn btn-sm"
onClick={() =>
setForm((f) => ({
...f,
titleSummarizerProvider: resolvedDefaultModel.provider,
titleSummarizerModelId: resolvedDefaultModel.modelId,
}))
}
disabled={!resolvedDefaultModel.provider || !resolvedDefaultModel.modelId}
>
Use default model
</button>
</div>
</div>
</>
<p className="settings-description">
{t(
"settings.movedStub.summarizerModelInline",
"The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.",
)}
</p>
)}
</>
);
@@ -3401,36 +3233,13 @@ export function SettingsModal({
<div className="settings-section-divider" />
<h5 className="settings-section-heading">Step Execution</h5>
<div className="form-group">
<label htmlFor="runStepsInNewSessions" className="checkbox-label">
<input
id="runStepsInNewSessions"
type="checkbox"
checked={form.runStepsInNewSessions || false}
onChange={(e) =>
setForm((f) => ({ ...f, runStepsInNewSessions: e.target.checked }))
}
/>
Run each step in a new session
</label>
<small>Run each task step in its own fresh agent session for better isolation and error recovery. Failed steps can be retried individually.</small>
</div>
<div className="form-group">
<label htmlFor="maxParallelSteps">Maximum parallel steps</label>
<input
id="maxParallelSteps"
type="number"
min={1}
max={4}
value={form.maxParallelSteps ?? ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, maxParallelSteps: val === "" ? undefined : Number(val) }));
}}
disabled={!form.runStepsInNewSessions}
/>
<small>Maximum number of steps to run in parallel when file scopes don&apos;t overlap (1-4)</small>
</div>
<MovedSettingsStub
message={t(
"settings.movedStub.stepExecution",
"Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
</>
);
case "scheduled-evals": {
@@ -4052,59 +3861,13 @@ export function SettingsModal({
<small>Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.</small>
</details>
</div>
<div className="form-group">
<label htmlFor="workflowRevisionForkOnScopeMismatch" className="checkbox-label">
<input
id="workflowRevisionForkOnScopeMismatch"
type="checkbox"
checked={form.workflowRevisionForkOnScopeMismatch !== false}
onChange={(e) =>
setForm((f) => ({ ...f, workflowRevisionForkOnScopeMismatch: e.target.checked }))
}
/>
Fork scope-mismatched workflow revisions into follow-up tasks
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
When enabled, workflow revision feedback that explicitly names files outside the original task&apos;s declared File Scope is split into a dependent follow-up task instead of being appended to the current task&apos;s PROMPT.md.
</small>
</details>
</div>
<div className="form-group">
<label htmlFor="verificationFixRetries">Verification auto-fix retries</label>
<input
id="verificationFixRetries"
className="input"
type="number"
min={0}
max={3}
step={1}
value={form.verificationFixRetries ?? 3}
onChange={(e) => {
const rawValue = e.target.value;
if (rawValue === "") {
setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState));
return;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue)) {
setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState));
return;
}
const clampedValue = Math.max(0, Math.min(3, parsedValue));
setForm((f) => ({ ...f, verificationFixRetries: clampedValue } as SettingsFormState));
}}
/>
<details className="settings-option-details">
<summary>More details</summary>
<small>
Controls auto-fix retry attempts after deterministic test/build verification failures — applies to both executor-time and in-merge verification (0-3).
</small>
</details>
</div>
<MovedSettingsStub
message={t(
"settings.movedStub.reviewVerification",
"Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
<div className="form-group">
<label htmlFor="mergeStrategy">Auto-completion mode</label>
<select
@@ -4304,27 +4067,6 @@ export function SettingsModal({
</div>
</>
)}
{form.mergeStrategy === "pull-request" && (
<div className="form-group">
<label htmlFor="requirePrApproval" className="checkbox-label">
<input
id="requirePrApproval"
type="checkbox"
checked={form.requirePrApproval ?? false}
onChange={(e) =>
setForm((f) => ({ ...f, requirePrApproval: e.target.checked }))
}
/>
Wait for an approving review before merging the PR
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>
When enabled, Fusion holds the PR in In Review until at least one approving GitHub review has been submitted. Useful on free private repos where GitHub&apos;s required-reviewer enforcement isn&apos;t available — without this, a fresh PR with no required checks is treated as immediately mergeable.
</small>
</details>
</div>
)}
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Authentication</h4>
<div className="form-group">
<label htmlFor="githubAuthMode">GitHub auth mode</label>
@@ -5386,28 +5128,7 @@ export function SettingsModal({
/>
);
case "prompts":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Prompts</h4>
<AgentPromptsManager
value={form.agentPrompts}
onChange={(agentPrompts: AgentPromptsConfig) => {
setForm((f) => ({
...f,
agentPrompts,
}));
}}
promptOverrides={form.promptOverrides}
onPromptOverridesChange={(overrides) => {
setForm((f) => ({
...f,
promptOverrides: overrides,
}));
}}
/>
</>
);
return <PromptsSection scopeBanner={renderScopeBanner()} form={form} setForm={setForm} />;
case "plugins":
return (
<>

View File

@@ -1273,7 +1273,10 @@ describe("SettingsModal", () => {
expect(screen.queryByText("Title, commit message, and GitHub tracking issue summarization model")).not.toBeInTheDocument();
});
it("shows summarization model picker for GitHub tracking defaults", async () => {
it("shows a moved-to-workflow note for the summarizer model when GitHub tracking defaults are on", async () => {
// The title-summarizer model lane was hard-moved (U4) onto workflow
// settings; the Project Models section now surfaces a moved-to-workflow
// note instead of an inline picker.
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
githubTrackingEnabledByDefault: true,
@@ -1284,7 +1287,10 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByRole("button", { name: "Project Models" }));
expect(screen.getByText("Title, commit message, and GitHub tracking issue summarization model")).toBeInTheDocument();
expect(
screen.queryByText("Title, commit message, and GitHub tracking issue summarization model"),
).not.toBeInTheDocument();
expect(screen.getByText(/model used for summarization now lives on the workflow/i)).toBeInTheDocument();
});
it("picks a project repo suggestion and preserves label association", async () => {
@@ -1569,7 +1575,7 @@ describe("SettingsModal", () => {
await waitForSettingsModalReady();
expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument();
await userEvent.click(screen.getByText("Scheduling"));
await userEvent.click(screen.getByText("Scheduling & Capacity"));
expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
expect(addToast).not.toHaveBeenCalled();
});
@@ -2383,7 +2389,7 @@ describe("SettingsModal", () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
expect(screen.getByDisplayValue("docs/")).toBeInTheDocument();
expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument();
@@ -2393,7 +2399,7 @@ describe("SettingsModal", () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
@@ -2407,7 +2413,7 @@ describe("SettingsModal", () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
await userEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i }));
await userEvent.click(await screen.findByRole("button", { name: "Select README.md" }));
@@ -2435,7 +2441,7 @@ describe("SettingsModal", () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement;
expect(select.value).toBe("lite");
@@ -2458,7 +2464,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Scheduling section
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
expect(input).toBeDefined();
@@ -2473,7 +2479,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Scheduling section
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
expect(input).toBeDefined();
@@ -2488,7 +2494,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Scheduling section
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
expect(input).toBeDefined();
@@ -2502,7 +2508,7 @@ describe("SettingsModal", () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
fireEvent.click(screen.getByText("Scheduling & Capacity"));
const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement;
expect(input).toBeDefined();
@@ -3166,26 +3172,20 @@ describe("SettingsModal", () => {
expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeVisible();
});
it("loads workflow revision fork checkbox from project settings", () => {
const checkbox = screen.getByRole("checkbox", {
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
});
expect(checkbox).toBeChecked();
it("no longer renders the moved workflow revision fork checkbox", () => {
// workflowRevisionForkOnScopeMismatch was hard-moved (U4) onto workflow
// settings; the Merge section must not expose it anymore.
expect(
screen.queryByRole("checkbox", {
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
}),
).not.toBeInTheDocument();
});
it("saves workflow revision fork checkbox changes", async () => {
const checkbox = screen.getByRole("checkbox", {
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
});
await userEvent.click(checkbox);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.workflowRevisionForkOnScopeMismatch).toBe(false);
it("renders a redirect stub for the moved review/verification settings", () => {
expect(
screen.getByText(/Review, verification auto-fix, and scope-enforcement settings now live on the workflow/i),
).toBeInTheDocument();
});
it("shows Push Remote input when push-after-merge is enabled", async () => {
@@ -3218,64 +3218,38 @@ describe("SettingsModal", () => {
});
});
describe("verificationFixRetries", () => {
it("shows default value 3 when verificationFixRetries is not set", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
verificationFixRetries: undefined,
});
describe("verificationFixRetries (moved to workflow settings)", () => {
// verificationFixRetries was hard-moved (U4) onto workflow settings. The
// Merge section must not expose it anymore — neither input nor save path.
it("no longer renders the verification auto-fix retries input", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
expect(retriesInput.value).toBe("3");
expect(screen.queryByLabelText("Verification auto-fix retries")).not.toBeInTheDocument();
});
it.each([0, 1, 2, 3])("persists valid value %i", async (value) => {
it("never sends verificationFixRetries through the save payload", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
fireEvent.change(retriesInput, { target: { value: String(value) } });
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.verificationFixRetries).toBe(value);
expect(payload).not.toHaveProperty("verificationFixRetries");
});
it("clamps out-of-range values", async () => {
renderModal({ initialSection: "merge" });
it("opens workflow settings from the redirect stub", async () => {
const onOpenWorkflowSettings = vi.fn();
renderModal({ initialSection: "merge", onOpenWorkflowSettings });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
fireEvent.change(retriesInput, { target: { value: "5" } });
expect(retriesInput.value).toBe("3");
fireEvent.change(retriesInput, { target: { value: "-1" } });
expect(retriesInput.value).toBe("0");
});
it("saving after clearing input persists undefined and falls back to visible default 3", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
await userEvent.clear(retriesInput);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.verificationFixRetries).toBeUndefined();
expect(retriesInput.value).toBe("3");
const buttons = screen.getAllByRole("button", { name: "Open workflow settings" });
await userEvent.click(buttons[0]);
expect(onOpenWorkflowSettings).toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,11 @@ import { render, cleanup, act } from "@testing-library/react";
import { Board } from "../Board";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../hooks/useCliSessions", () => ({
useCliSessions: () => ({ sessions: [], previews: {}, loading: false, refresh: () => {} }),
}));
vi.mock("../../api", () => ({
fetchCliSessions: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
}));

View File

@@ -0,0 +1,40 @@
/* MovedSettingsStub (U9 / KTD-5) — redirect stub for hard-moved settings. */
.settings-moved-stub {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-2, var(--surface));
}
.settings-moved-stub__message {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.settings-moved-stub__action {
align-self: flex-start;
padding: var(--space-xs) var(--space-md);
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.settings-moved-stub__action:hover:not(:disabled) {
background: var(--surface-hover, var(--surface-2, var(--surface)));
border-color: var(--accent, var(--border));
}
.settings-moved-stub__action:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,47 @@
/**
* Redirect stub for moved settings (U9 / KTD-5, R10).
*
* The step-execution, review/approval, and per-phase model-lane settings that
* used to live inline in the Project group's Scheduling / Merge / Project Models
* sections were hard-moved (U4) onto the workflow settings mechanism — they no
* longer exist as project settings keys and must never be renderable or savable
* from this modal again. Where a section lost that content, this shared stub
* renders in its place: a short explanation plus a button that closes the
* Settings modal and opens the workflow node editor with its Settings panel
* pre-selected (`initialPanel="settings"`) for the project's default workflow.
*
* Per KTD-5's one-release rule, sections whose content moved entirely keep their
* nav entry this release showing only this stub.
*/
import { useTranslation } from "react-i18next";
import "./MovedSettingsStub.css";
export interface MovedSettingsStubProps {
/** Localized lead sentence describing what moved. */
message: string;
/**
* Closes the Settings modal and opens the workflow editor on its Settings
* panel for the project's default workflow. May be undefined when no host
* wiring is available (e.g. isolated rendering) — the button is then disabled.
*/
onOpenWorkflowSettings?: () => void;
}
export function MovedSettingsStub({ message, onOpenWorkflowSettings }: MovedSettingsStubProps) {
const { t } = useTranslation("app");
return (
<div className="settings-moved-stub" role="note">
<p className="settings-moved-stub__message">{message}</p>
<button
type="button"
className="settings-moved-stub__action"
onClick={onOpenWorkflowSettings}
disabled={!onOpenWorkflowSettings}
>
{t("settings.movedStub.openWorkflowSettings", "Open workflow settings")}
</button>
</div>
);
}
export default MovedSettingsStub;

View File

@@ -0,0 +1,38 @@
/**
* Prompts section (U9 / KTD-10).
*
* Project-group section wrapping AgentPromptsManager. Presentational: it reads
* `agentPrompts`/`promptOverrides` off the modal form and relays edits back
* through `setForm`; the shell keeps persistence + save-split.
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { AgentPromptsConfig } from "@fusion/core";
import { AgentPromptsManager } from "../../AgentPromptsManager";
import type { SectionBaseProps } from "./context";
export interface PromptsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
}
export function PromptsSection({ scopeBanner, form, setForm }: PromptsSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.nav.prompts", "Prompts")}</h4>
<AgentPromptsManager
value={form.agentPrompts}
onChange={(agentPrompts: AgentPromptsConfig) => {
setForm((f) => ({ ...f, agentPrompts }));
}}
promptOverrides={form.promptOverrides}
onPromptOverridesChange={(overrides) => {
setForm((f) => ({ ...f, promptOverrides: overrides }));
}}
/>
</>
);
}
export default PromptsSection;

View File

@@ -0,0 +1,29 @@
/**
* Secrets section (U9 / KTD-10).
*
* Thin Project-group wrapper around the self-contained SecretsView card. Carries
* no modal form state — the shell owns persistence; this section only titles and
* mounts the relocated card (mirrors the RuntimesSections convention).
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SecretsView } from "../../SecretsView";
import type { ToastType } from "../../../hooks/useToast";
export interface SecretsSectionProps {
scopeBanner: ReactNode;
addToast: (message: string, type?: ToastType) => void;
}
export function SecretsSection({ scopeBanner, addToast }: SecretsSectionProps) {
const { t } = useTranslation("app");
return (
<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.nav.secrets", "Secrets")}</h4>
<SecretsView addToast={addToast} />
</>
);
}
export default SecretsSection;

View File

@@ -55,6 +55,8 @@ export interface ModalManager {
gitManagerOpen: boolean;
workflowStepsOpen: boolean;
workflowEditorOpen: boolean;
/** When the workflow editor opens, which internal panel to pre-select (U9 redirect stubs). */
workflowEditorInitialPanel?: "settings";
agentsOpen: boolean;
scriptsOpen: boolean;
setupWizardOpen: boolean;
@@ -119,7 +121,7 @@ export interface ModalManager {
openWorkflowSteps: () => void;
closeWorkflowSteps: () => void;
openWorkflowEditor: () => void;
openWorkflowEditor: (initialPanel?: "settings") => void;
closeWorkflowEditor: () => void;
openAgents: () => void;
@@ -179,6 +181,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
const [workflowEditorInitialPanel, setWorkflowEditorInitialPanel] = useState<"settings" | undefined>(undefined);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
@@ -347,8 +350,14 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openWorkflowSteps = useCallback(() => setWorkflowStepsOpen(true), []);
const closeWorkflowSteps = useCallback(() => setWorkflowStepsOpen(false), []);
const openWorkflowEditor = useCallback(() => setWorkflowEditorOpen(true), []);
const closeWorkflowEditor = useCallback(() => setWorkflowEditorOpen(false), []);
const openWorkflowEditor = useCallback((initialPanel?: "settings") => {
setWorkflowEditorInitialPanel(initialPanel);
setWorkflowEditorOpen(true);
}, []);
const closeWorkflowEditor = useCallback(() => {
setWorkflowEditorOpen(false);
setWorkflowEditorInitialPanel(undefined);
}, []);
const openAgents = useCallback(() => setAgentsOpen(true), []);
const closeAgents = useCallback(() => setAgentsOpen(false), []);
@@ -416,6 +425,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
gitManagerOpen,
workflowStepsOpen,
workflowEditorOpen,
workflowEditorInitialPanel,
agentsOpen,
scriptsOpen,
setupWizardOpen,

View File

@@ -5050,6 +5050,13 @@
"openApprovals": "Open Approvals",
"selectWorktreesDir": "Select worktrees directory",
"tryAgain": "Try again"
},
"movedStub": {
"openWorkflowSettings": "Open workflow settings",
"stepExecution": "Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.",
"reviewVerification": "Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
"modelLanes": "Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
"summarizerModelInline": "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it."
}
},
"setup": {