From 3a3360415976f20a2d9e67110051910e56f0bd32 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 00:46:59 -0700 Subject: [PATCH] feat(dashboard): remove moved settings from SettingsModal with workflow-editor redirect stubs, target-IA regroup, secrets/prompts extraction --- .../app/__tests__/settings-moved-keys.test.ts | 65 +++ .../app/__tests__/settings-sections.test.tsx | 46 ++ .../dashboard/app/components/AppModals.tsx | 5 + .../app/components/SettingsModal.tsx | 393 +++--------------- .../__tests__/SettingsModal.test.tsx | 112 ++--- .../board-mobile-initial-render.test.tsx | 4 + .../settings/sections/MovedSettingsStub.css | 40 ++ .../settings/sections/MovedSettingsStub.tsx | 47 +++ .../settings/sections/PromptsSection.tsx | 38 ++ .../settings/sections/SecretsSection.tsx | 29 ++ .../dashboard/app/hooks/useModalManager.ts | 16 +- packages/i18n/locales/en/app.json | 7 + 12 files changed, 394 insertions(+), 408 deletions(-) create mode 100644 packages/dashboard/app/__tests__/settings-moved-keys.test.ts create mode 100644 packages/dashboard/app/components/settings/sections/MovedSettingsStub.css create mode 100644 packages/dashboard/app/components/settings/sections/MovedSettingsStub.tsx create mode 100644 packages/dashboard/app/components/settings/sections/PromptsSection.tsx create mode 100644 packages/dashboard/app/components/settings/sections/SecretsSection.tsx diff --git a/packages/dashboard/app/__tests__/settings-moved-keys.test.ts b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts new file mode 100644 index 0000000000..2ca176afeb --- /dev/null +++ b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts @@ -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.` read and no `:` 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.` and `:`) 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.` 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.` (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: `:`. + // Allowed: descriptor table entries (`projectProviderKey: ""`), + // 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); + }); + } + } + } +}); diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index d6360780a0..37f300048d 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -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: () =>
, +})); +vi.mock("../components/SecretsView", () => ({ + SecretsView: () =>
, +})); + expect.extend(jestDomMatchers); afterEach(() => cleanup()); @@ -99,6 +109,42 @@ describe("NotificationsSection", () => { }); }); +describe("SecretsSection", () => { + it("renders the scope banner, title, and the SecretsView card", () => { + render( + } 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( + , + ); + 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(); + 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(); + expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled(); + }); +}); + describe("ExperimentalSection", () => { const knownFeatures = { insights: "Insights", roadmap: "Roadmaps" }; const legacyAliases: Record = { devServer: "devServerView" }; diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 9b543557c1..f208b4747f 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -242,6 +242,10 @@ export function AppModals({ onDashboardFontScaleChange={settings.setDashboardFontScalePct} onReopenOnboarding={onReopenOnboarding} onOpenApprovals={onOpenApprovals} + onOpenWorkflowSettings={() => { + handleSettingsClose(); + modalManager.openWorkflowEditor("settings"); + }} /> @@ -394,6 +398,7 @@ export function AppModals({ onClose={modalManager.closeWorkflowEditor} addToast={addToast} projectId={projectId} + initialPanel={modalManager.workflowEditorInitialPanel} /> diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index a1111c95d7..793ffaee4c 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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({ New tasks inherit this custom workflow's steps (overridable per task)
-
- - When enabled, AI-generated task specifications require manual approval before moving to Todo -
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && ( - <> -
- - {modelsLoading ? ( - Loading available models... - ) : availableModels.length === 0 ? ( - No models available. Configure authentication first. - ) : ( - { - 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} - /> - )} - - Also used to summarize task descriptions into GitHub tracking issue titles when a task has no title yet. - - - {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)"} - -
- -
-
- - -
-
- +

+ {t( + "settings.movedStub.summarizerModelInline", + "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.", + )} +

)} ); @@ -3401,36 +3233,13 @@ export function SettingsModal({
Step Execution
-
- - Run each task step in its own fresh agent session for better isolation and error recovery. Failed steps can be retried individually. -
-
- - { - const val = e.target.value; - setForm((f) => ({ ...f, maxParallelSteps: val === "" ? undefined : Number(val) })); - }} - disabled={!form.runStepsInNewSessions} - /> - Maximum number of steps to run in parallel when file scopes don't overlap (1-4) -
+ ); case "scheduled-evals": { @@ -4052,59 +3861,13 @@ export function SettingsModal({ Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.
-
- -
- More details - - 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. - -
-
-
- - { - 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)); - }} - /> -
- More details - - Controls auto-fix retry attempts after deterministic test/build verification failures — applies to both executor-time and in-merge verification (0-3). - -
-
+
- setForm((f) => ({ ...f, requirePrApproval: e.target.checked })) - } - /> - Wait for an approving review before merging the PR - -
- More details - - 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. - -
-
- )}

GitHub Authentication

@@ -5386,28 +5128,7 @@ export function SettingsModal({ /> ); case "prompts": - return ( - <> - {renderScopeBanner()} -

Prompts

- { - setForm((f) => ({ - ...f, - agentPrompts, - })); - }} - promptOverrides={form.promptOverrides} - onPromptOverridesChange={(overrides) => { - setForm((f) => ({ - ...f, - promptOverrides: overrides, - })); - }} - /> - - ); + return ; case "plugins": return ( <> diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 19a96af615..ae149f6cbd 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -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; - 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; - 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; - 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(); }); }); diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx index 038e12dc0f..14de496d83 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx @@ -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([]), })); diff --git a/packages/dashboard/app/components/settings/sections/MovedSettingsStub.css b/packages/dashboard/app/components/settings/sections/MovedSettingsStub.css new file mode 100644 index 0000000000..d227252740 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/MovedSettingsStub.css @@ -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; +} diff --git a/packages/dashboard/app/components/settings/sections/MovedSettingsStub.tsx b/packages/dashboard/app/components/settings/sections/MovedSettingsStub.tsx new file mode 100644 index 0000000000..92042a8448 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/MovedSettingsStub.tsx @@ -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 ( +
+

{message}

+ +
+ ); +} + +export default MovedSettingsStub; diff --git a/packages/dashboard/app/components/settings/sections/PromptsSection.tsx b/packages/dashboard/app/components/settings/sections/PromptsSection.tsx new file mode 100644 index 0000000000..a79947fa5a --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/PromptsSection.tsx @@ -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} +

{t("settings.nav.prompts", "Prompts")}

+ { + setForm((f) => ({ ...f, agentPrompts })); + }} + promptOverrides={form.promptOverrides} + onPromptOverridesChange={(overrides) => { + setForm((f) => ({ ...f, promptOverrides: overrides })); + }} + /> + + ); +} + +export default PromptsSection; diff --git a/packages/dashboard/app/components/settings/sections/SecretsSection.tsx b/packages/dashboard/app/components/settings/sections/SecretsSection.tsx new file mode 100644 index 0000000000..b7f30cd3e6 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/SecretsSection.tsx @@ -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} +

{t("settings.nav.secrets", "Secrets")}

+ + + ); +} + +export default SecretsSection; diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 4e08e301b1..44709f9b5b 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -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, diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 0960526f43..186459b2db 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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": {