feat(dashboard): remove moved settings from SettingsModal with workflow-editor redirect stubs, target-IA regroup, secrets/prompts extraction
This commit is contained in:
65
packages/dashboard/app/__tests__/settings-moved-keys.test.ts
Normal file
65
packages/dashboard/app/__tests__/settings-moved-keys.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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's declared File Scope is split into a dependent follow-up task instead of being appended to the current task'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's required-reviewer enforcement isn'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 (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user