FN-8563: preserve new task edits before defaults load

Treat asynchronous New Task defaults as pristine while retaining operator changes for discard confirmation.

- Track initialization versus user writes for workflow steps, models, and GitHub defaults
- Prevent delayed model auto-preset selection from overwriting an operator-selected custom model
- Cover blank-modal close paths and the delayed-settings custom-model regression

Files changed:
 .changeset/fn-8563-pristine-new-task-close.md      |   7 ++
 packages/dashboard/app/components/NewTaskModal.tsx |  68 +++++++++--
 packages/dashboard/app/components/TaskForm.tsx     |  58 ++++++---
 .../app/components/__tests__/NewTaskModal.test.tsx | 134 +++++++++++++++++++++
 4 files changed, 235 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-8563

Fusion-Task-Lineage: d70c1e41-37b4-4cb6-bcf7-9bd7dd64ac39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-24 18:43:29 -07:00
parent cc053915f3
commit f3a69497dc
4 changed files with 235 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Close untouched New Task dialogs without a discard confirmation.
category: fix
dev: Workflow optional-step initialization is no longer classified as an operator edit.

View File

@@ -21,7 +21,7 @@ import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { LoadingSpinner } from "./LoadingSpinner";
import { TaskForm, type BranchSelectionMode, type EnabledWorkflowStepsChangeMeta, type PendingImage } from "./TaskForm";
import { TaskForm, type BranchSelectionMode, type EnabledWorkflowStepsChangeMeta, type PendingImage, type TaskFormValueChangeMeta } from "./TaskForm";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm";
@@ -589,6 +589,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
// from the selected workflow's defaultOn and lifts the enabled set up here.
const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]);
const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false);
const [hasUserSelectedEnabledWorkflowSteps, setHasUserSelectedEnabledWorkflowSteps] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
@@ -603,15 +604,35 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [executionMode, setExecutionMode] = useState<"standard" | "fast">("standard");
const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false);
/*
FNXC:NewTaskDirtyState 2026-07-24-14:00:
Asynchronous model-preset and GitHub-tracking defaults are create-form initialization,
not operator edits. Preserve their settled values as the pristine baseline so a blank
modal closes directly, while a later operator change still retains discard protection.
*/
const [initialDefaultValues, setInitialDefaultValues] = useState({
executorModel: "",
validatorModel: "",
githubTrackingEnabled: false,
});
/*
FNXC:FastOptionalSteps 2026-06-30-09:10:
New task create payloads must distinguish omitted optional-step intent (no controls/no workflow; allow store defaults) from explicit `[]` (operator chose Fast or deselected all; do not re-seed default-on groups) and non-empty manual selections.
FNXC:FastOptionalSteps 2026-06-30-10:42:
Fast is itself explicit optional-step intent. Submit the current enabledWorkflowSteps array even before optional-step metadata finishes loading so default-on workflow gates cannot revive through an omitted field.
*/
/*
FNXC:NewTaskDirtyState 2026-07-24-12:15:
TaskForm asynchronously seeds inherited workflow defaults so creation can submit an explicit
optional-step selection. That initialization is not operator input and must not trigger the
discard dialog; only a user optional-step action is dirty while the seeded payload is preserved.
*/
const handleEnabledWorkflowStepsChange = useCallback((ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => {
setEnabledWorkflowSteps(ids);
setShouldSubmitEnabledWorkflowSteps(meta?.optionalStepsAvailable === true);
if (meta?.source === "user") {
setHasUserSelectedEnabledWorkflowSteps(true);
}
}, []);
const [githubRepoOverride, setGithubRepoOverride] = useState("");
@@ -711,6 +732,27 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group";
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim();
const handleExecutorModelChange = useCallback((value: string, meta?: TaskFormValueChangeMeta) => {
setExecutorModel(value);
if (meta?.source === "initialization") {
setInitialDefaultValues((defaults) => ({ ...defaults, executorModel: value }));
}
}, []);
const handleValidatorModelChange = useCallback((value: string, meta?: TaskFormValueChangeMeta) => {
setValidatorModel(value);
if (meta?.source === "initialization") {
setInitialDefaultValues((defaults) => ({ ...defaults, validatorModel: value }));
}
}, []);
const handleGithubTrackingEnabledChange = useCallback((value: boolean, meta?: TaskFormValueChangeMeta) => {
setGithubTrackingEnabled(value);
if (meta?.source === "initialization") {
setInitialDefaultValues((defaults) => ({ ...defaults, githubTrackingEnabled: value }));
}
}, []);
// Track dirty state
useEffect(() => {
const isDirty =
@@ -718,13 +760,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
dependencies.length > 0 ||
pendingImages.length > 0 ||
selectedWorkflowId !== undefined ||
// Optional workflow steps the user toggled count as unsaved work. (Workflows
// whose steps are defaultOn:false — today's only shipped step — seed an empty
// set, so this stays false until the user actually opts a step in.)
shouldSubmitEnabledWorkflowSteps ||
enabledWorkflowSteps.length > 0 ||
executorModel !== "" ||
validatorModel !== "" ||
// The create payload preserves asynchronously seeded defaultOn steps, but only
// an operator toggle should require discard confirmation.
hasUserSelectedEnabledWorkflowSteps ||
executorModel !== initialDefaultValues.executorModel ||
validatorModel !== initialDefaultValues.validatorModel ||
planningModel !== "" ||
thinkingLevel !== "" ||
plannerOversightLevel !== "" ||
@@ -737,10 +777,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
branchMode !== "project-default" ||
branch !== "" ||
baseBranch !== "" ||
githubTrackingEnabled ||
githubTrackingEnabled !== initialDefaultValues.githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
}, [description, dependencies, pendingImages, selectedWorkflowId, hasUserSelectedEnabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, initialDefaultValues]);
const resetForm = useCallback(() => {
// Clean up object URLs
@@ -759,6 +799,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setSelectedWorkflowId(undefined);
setEnabledWorkflowSteps([]);
setShouldSubmitEnabledWorkflowSteps(false);
setHasUserSelectedEnabledWorkflowSteps(false);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
@@ -771,6 +812,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setBaseBranch("");
setHasDirtyState(false);
setGithubTrackingEnabled(false);
setInitialDefaultValues({ executorModel: "", validatorModel: "", githubTrackingEnabled: false });
setGithubRepoOverride("");
setDuplicateMatches(null);
githubGeneratedDescriptionRef.current = "";
@@ -1173,9 +1215,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
dependencies={dependencies}
onDependenciesChange={setDependencies}
executorModel={executorModel}
onExecutorModelChange={setExecutorModel}
onExecutorModelChange={handleExecutorModelChange}
validatorModel={validatorModel}
onValidatorModelChange={setValidatorModel}
onValidatorModelChange={handleValidatorModelChange}
presetMode={presetMode}
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
@@ -1218,7 +1260,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
executionMode={executionMode}
onExecutionModeChange={setExecutionMode}
githubTrackingEnabled={githubTrackingEnabled}
onGithubTrackingEnabledChange={setGithubTrackingEnabled}
onGithubTrackingEnabledChange={handleGithubTrackingEnabledChange}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
onCreateSubmit={handleSubmit}

View File

@@ -55,6 +55,13 @@ type TaskExecutionModeSelection = "standard" | "fast";
export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new" | "shared-group";
export interface EnabledWorkflowStepsChangeMeta {
optionalStepsAvailable: boolean;
/** Distinguishes automatic create-form seeding from an operator optional-step choice. */
source?: "initialization" | "user";
}
/** Identifies form writes made by asynchronous create-form defaults rather than an operator. */
export interface TaskFormValueChangeMeta {
source?: "initialization" | "user";
}
const PRESET_OPTION_SEPARATOR = "──────────";
@@ -87,9 +94,9 @@ export interface TaskFormProps {
priority?: TaskPriority;
onPriorityChange?: (value: TaskPriority) => void;
executorModel: string;
onExecutorModelChange: (value: string) => void;
onExecutorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
validatorModel: string;
onValidatorModelChange: (value: string) => void;
onValidatorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
planningModel?: string;
onPlanningModelChange?: (value: string) => void;
thinkingLevel?: string;
@@ -151,7 +158,7 @@ export interface TaskFormProps {
executionMode?: TaskExecutionModeSelection;
onExecutionModeChange?: (value: TaskExecutionModeSelection) => void;
githubTrackingEnabled?: boolean;
onGithubTrackingEnabledChange?: (value: boolean) => void;
onGithubTrackingEnabledChange?: (value: boolean, meta?: TaskFormValueChangeMeta) => void;
githubRepoOverride?: string;
onGithubRepoOverrideChange?: (value: string) => void;
@@ -375,7 +382,7 @@ export function TaskForm({
// mid-flight when switching to "No workflow"), so the loading row never sticks.
setOptionalStepsLoading(false);
if (isCreateOptionalStepPicker) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false, source: "initialization" });
}
return;
}
@@ -392,14 +399,14 @@ export function TaskForm({
const seededSteps = executionModeRef.current === "fast"
? []
: steps.filter((s) => s.defaultOn).map((s) => s.templateId);
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 });
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0, source: "initialization" });
}
})
.catch(() => {
if (cancelled) return;
setOptionalSteps([]);
if (isCreateOptionalStepPicker) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false, source: "initialization" });
}
})
.finally(() => {
@@ -421,7 +428,7 @@ export function TaskForm({
const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => {
onExecutionModeChange?.(nextMode);
if (nextMode === "fast" && onWorkflowIdChange) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0 });
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0, source: "user" });
}
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, onWorkflowIdChange, optionalSteps.length]);
@@ -431,13 +438,19 @@ export function TaskForm({
const next = current.includes(templateId)
? current.filter((id) => id !== templateId)
: [...current, templateId];
onEnabledWorkflowStepsChange?.(next, { optionalStepsAvailable: optionalSteps.length > 0 });
onEnabledWorkflowStepsChange?.(next, { optionalStepsAvailable: optionalSteps.length > 0, source: "user" });
},
[enabledWorkflowSteps, onEnabledWorkflowStepsChange, optionalSteps.length],
);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
/*
FNXC:NewTaskDirtyState 2026-07-24-18:30:
Settings arrive asynchronously, but a model selected before they resolve is operator input.
Do not let the delayed auto-preset replace that selection or reclassify it as pristine.
*/
const hasUserSelectedModelRef = useRef(false);
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings);
const githubRepoOverrideTrimmed = (githubRepoOverride || "").trim();
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
@@ -463,14 +476,14 @@ export function TaskForm({
// Auto-select preset by size (create mode only)
useEffect(() => {
if (mode !== "create" || !isActive || !settings?.autoSelectModelPreset) return;
if (mode !== "create" || !isActive || !settings?.autoSelectModelPreset || hasUserSelectedModelRef.current) return;
const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets);
if (recommended) {
const selection = applyPresetToSelection(recommended);
onSelectedPresetIdChange(recommended.id);
onPresetModeChange("preset");
onExecutorModelChange(selection.executorValue);
onValidatorModelChange(selection.validatorValue);
onExecutorModelChange(selection.executorValue, { source: "initialization" });
onValidatorModelChange(selection.validatorValue, { source: "initialization" });
}
}, [isActive, settings, availablePresets, mode]);
@@ -483,7 +496,7 @@ export function TaskForm({
if (githubTrackingDefaultAppliedRef.current) return;
if (!settings) return;
onGithubTrackingEnabledChange(settings.githubTrackingEnabledByDefault ?? false);
onGithubTrackingEnabledChange(settings.githubTrackingEnabledByDefault ?? false, { source: "initialization" });
githubTrackingDefaultAppliedRef.current = true;
}, [mode, isActive, settings, onGithubTrackingEnabledChange]);
@@ -502,6 +515,7 @@ export function TaskForm({
useEffect(() => {
if (!isActive) {
githubTrackingDefaultAppliedRef.current = false;
hasUserSelectedModelRef.current = false;
}
}, [isActive]);
@@ -1480,11 +1494,12 @@ export function TaskForm({
value={presetMode === "preset" ? selectedPresetId : presetMode}
onChange={(e) => {
const value = e.target.value;
hasUserSelectedModelRef.current = true;
if (value === "default") {
onPresetModeChange("default");
onSelectedPresetIdChange("");
onExecutorModelChange("");
onValidatorModelChange("");
onExecutorModelChange("", { source: "user" });
onValidatorModelChange("", { source: "user" });
return;
}
if (value === "custom") {
@@ -1496,8 +1511,8 @@ export function TaskForm({
const selection = applyPresetToSelection(preset);
onPresetModeChange("preset");
onSelectedPresetIdChange(value);
onExecutorModelChange(selection.executorValue);
onValidatorModelChange(selection.validatorValue);
onExecutorModelChange(selection.executorValue, { source: "user" });
onValidatorModelChange(selection.validatorValue, { source: "user" });
}}
disabled={disabled}
>
@@ -1516,7 +1531,10 @@ export function TaskForm({
<button
type="button"
className="btn btn-sm"
onClick={() => onPresetModeChange("custom")}
onClick={() => {
hasUserSelectedModelRef.current = true;
onPresetModeChange("custom");
}}
disabled={disabled}
>
{t("taskForm.overridePreset", "Override")}
@@ -1529,9 +1547,10 @@ export function TaskForm({
label={t("taskForm.executorModelLabel", "Executor Model")}
value={executorModel}
onChange={(value) => {
hasUserSelectedModelRef.current = true;
onPresetModeChange("custom");
onSelectedPresetIdChange("");
onExecutorModelChange(value);
onExecutorModelChange(value, { source: "user" });
}}
models={availableModels}
disabled={disabled || presetMode === "preset"}
@@ -1551,9 +1570,10 @@ export function TaskForm({
label={t("taskForm.reviewerModelLabel", "Reviewer Model")}
value={validatorModel}
onChange={(value) => {
hasUserSelectedModelRef.current = true;
onPresetModeChange("custom");
onSelectedPresetIdChange("");
onValidatorModelChange(value);
onValidatorModelChange(value, { source: "user" });
}}
models={availableModels}
disabled={disabled || presetMode === "preset"}

View File

@@ -922,6 +922,140 @@ describe("NewTaskModal", () => {
});
});
/*
FNXC:NewTaskDirtyState 2026-07-24-12:00:
TaskForm's inherited workflow and optional-step fetches seed create payload metadata,
not operator input. A settled blank New Task modal must therefore close directly by
Close, Cancel, and Escape, while real field edits retain discard protection.
*/
it.each([
["mobile Close", "mobile", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Close" }))],
["mobile Cancel", "mobile", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))],
["mobile Escape", "mobile", (overlay: HTMLElement) => fireEvent.keyDown(overlay, { key: "Escape" })],
["desktop Close", "desktop", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Close" }))],
["desktop Cancel", "desktop", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))],
["desktop Escape", "desktop", (overlay: HTMLElement) => fireEvent.keyDown(overlay, { key: "Escape" })],
] as const)("closes a blank modal with inherited default-on steps through %s without confirmation", async (_closePath, viewport, close) => {
const { fetchSettings, fetchWorkflowOptionalSteps } = await import("../../api");
mockViewportMode = viewport;
vi.mocked(fetchSettings).mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, defaultWorkflowId: "wf-x" });
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]);
const { props } = renderNewTaskModal();
await waitFor(() => expect(screen.getByTestId("task-form-inline-optional-steps")).toHaveTextContent("Steps: 1 selected"));
close(screen.getByTestId("new-task-modal-overlay"));
await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(1));
expect(mockConfirm).not.toHaveBeenCalled();
});
it.each([
["Close", "mobile", () => fireEvent.click(screen.getByRole("button", { name: "Close" }))],
["Cancel", "desktop", () => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))],
["Escape", "mobile", () => fireEvent.keyDown(screen.getByTestId("new-task-modal-overlay"), { key: "Escape" })],
] as const)("closes directly after enabled automatic defaults through %s", async (_closePath, viewport, close) => {
const { fetchSettings } = await import("../../api");
mockViewportMode = viewport;
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [{ id: "default-preset", name: "Default preset", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
autoSelectModelPreset: true,
defaultPresetBySize: {},
githubTrackingEnabledByDefault: true,
});
const { props } = renderNewTaskModal();
await waitFor(() => expect(screen.getByTestId("task-form-inline-github")).toHaveAttribute("aria-pressed", "true"));
close();
await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(1));
expect(mockConfirm).not.toHaveBeenCalled();
});
it("preserves a custom model selected before settings load and confirms on close", async () => {
const { fetchSettings } = await import("../../api");
let resolveSettings!: (settings: any) => void;
vi.mocked(fetchSettings).mockReturnValueOnce(new Promise<any>((resolve) => {
resolveSettings = resolve;
}));
const { props } = renderNewTaskModal();
fireEvent.click(await screen.findByTestId("task-form-inline-models"));
const executor = await screen.findByRole("button", { name: "Executor Model" });
fireEvent.click(executor);
fireEvent.click(await screen.findByRole("option", { name: /GPT-4o/ }));
resolveSettings({
modelPresets: [{ id: "recommended", name: "Recommended", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
autoSelectModelPreset: true,
defaultPresetBySize: {},
});
await waitFor(() => expect(executor).toHaveTextContent("GPT-4o"));
mockConfirm.mockResolvedValueOnce(false);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Discard Changes" })));
expect(props.onClose).not.toHaveBeenCalled();
expect(executor).toHaveTextContent("GPT-4o");
});
it("still confirms when an operator changes an initialized GitHub default", async () => {
const { fetchSettings } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
githubTrackingEnabledByDefault: true,
});
const { props } = renderNewTaskModal();
const githubToggle = await screen.findByTestId("task-form-inline-github");
await waitFor(() => expect(githubToggle).toHaveAttribute("aria-pressed", "true"));
fireEvent.click(githubToggle);
mockConfirm.mockResolvedValueOnce(false);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Discard Changes" })));
expect(props.onClose).not.toHaveBeenCalled();
});
it.each([
["undefined inherited workflow", undefined, []],
["default workflow with no optional steps", "wf-x", []],
])("closes directly after $0 initialization metadata", async (_label, defaultWorkflowId, steps) => {
const { fetchSettings, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, ...(defaultWorkflowId ? { defaultWorkflowId } : {}) });
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue(steps);
const { props } = renderNewTaskModal();
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(1));
expect(mockConfirm).not.toHaveBeenCalled();
});
it("still confirms a user-selected optional step and keeps the modal open when discard is declined", async () => {
const { fetchSettings, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, defaultWorkflowId: "wf-x" });
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
const { props } = renderNewTaskModal();
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
fireEvent.click(trigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
mockConfirm.mockResolvedValueOnce(false);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Discard Changes" })));
expect(props.onClose).not.toHaveBeenCalled();
expect(screen.getByTestId("task-form-inline-optional-steps")).toHaveTextContent("Steps: 1 selected");
});
it("submits explicit empty optional steps when Fast is created before optional-step metadata loads", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);