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 { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner"; import { SetupWarningBanner } from "./SetupWarningBanner";
import { LoadingSpinner } from "./LoadingSpinner"; 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 { DuplicateWarningModal } from "./DuplicateWarningModal";
import { REPO_OVERRIDE_RE } from "./githubTracking"; import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm"; 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. // from the selected workflow's defaultOn and lifts the enabled set up here.
const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]); const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]);
const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false); const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false);
const [hasUserSelectedEnabledWorkflowSteps, setHasUserSelectedEnabledWorkflowSteps] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined); const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined); const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY); 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 [executionMode, setExecutionMode] = useState<"standard" | "fast">("standard");
const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false); 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: 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. 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: 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. 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) => { const handleEnabledWorkflowStepsChange = useCallback((ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => {
setEnabledWorkflowSteps(ids); setEnabledWorkflowSteps(ids);
setShouldSubmitEnabledWorkflowSteps(meta?.optionalStepsAvailable === true); setShouldSubmitEnabledWorkflowSteps(meta?.optionalStepsAvailable === true);
if (meta?.source === "user") {
setHasUserSelectedEnabledWorkflowSteps(true);
}
}, []); }, []);
const [githubRepoOverride, setGithubRepoOverride] = useState(""); 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 isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group";
const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim(); 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 // Track dirty state
useEffect(() => { useEffect(() => {
const isDirty = const isDirty =
@@ -718,13 +760,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
dependencies.length > 0 || dependencies.length > 0 ||
pendingImages.length > 0 || pendingImages.length > 0 ||
selectedWorkflowId !== undefined || selectedWorkflowId !== undefined ||
// Optional workflow steps the user toggled count as unsaved work. (Workflows // The create payload preserves asynchronously seeded defaultOn steps, but only
// whose steps are defaultOn:false — today's only shipped step — seed an empty // an operator toggle should require discard confirmation.
// set, so this stays false until the user actually opts a step in.) hasUserSelectedEnabledWorkflowSteps ||
shouldSubmitEnabledWorkflowSteps || executorModel !== initialDefaultValues.executorModel ||
enabledWorkflowSteps.length > 0 || validatorModel !== initialDefaultValues.validatorModel ||
executorModel !== "" ||
validatorModel !== "" ||
planningModel !== "" || planningModel !== "" ||
thinkingLevel !== "" || thinkingLevel !== "" ||
plannerOversightLevel !== "" || plannerOversightLevel !== "" ||
@@ -737,10 +777,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
branchMode !== "project-default" || branchMode !== "project-default" ||
branch !== "" || branch !== "" ||
baseBranch !== "" || baseBranch !== "" ||
githubTrackingEnabled || githubTrackingEnabled !== initialDefaultValues.githubTrackingEnabled ||
githubRepoOverrideTrimmed !== ""; githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty); 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(() => { const resetForm = useCallback(() => {
// Clean up object URLs // Clean up object URLs
@@ -759,6 +799,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setSelectedWorkflowId(undefined); setSelectedWorkflowId(undefined);
setEnabledWorkflowSteps([]); setEnabledWorkflowSteps([]);
setShouldSubmitEnabledWorkflowSteps(false); setShouldSubmitEnabledWorkflowSteps(false);
setHasUserSelectedEnabledWorkflowSteps(false);
setSelectedAgentId(null); setSelectedAgentId(null);
setShowAgentPicker(false); setShowAgentPicker(false);
setReviewLevel(undefined); setReviewLevel(undefined);
@@ -771,6 +812,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setBaseBranch(""); setBaseBranch("");
setHasDirtyState(false); setHasDirtyState(false);
setGithubTrackingEnabled(false); setGithubTrackingEnabled(false);
setInitialDefaultValues({ executorModel: "", validatorModel: "", githubTrackingEnabled: false });
setGithubRepoOverride(""); setGithubRepoOverride("");
setDuplicateMatches(null); setDuplicateMatches(null);
githubGeneratedDescriptionRef.current = ""; githubGeneratedDescriptionRef.current = "";
@@ -1173,9 +1215,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
dependencies={dependencies} dependencies={dependencies}
onDependenciesChange={setDependencies} onDependenciesChange={setDependencies}
executorModel={executorModel} executorModel={executorModel}
onExecutorModelChange={setExecutorModel} onExecutorModelChange={handleExecutorModelChange}
validatorModel={validatorModel} validatorModel={validatorModel}
onValidatorModelChange={setValidatorModel} onValidatorModelChange={handleValidatorModelChange}
presetMode={presetMode} presetMode={presetMode}
onPresetModeChange={setPresetMode} onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId} selectedPresetId={selectedPresetId}
@@ -1218,7 +1260,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
executionMode={executionMode} executionMode={executionMode}
onExecutionModeChange={setExecutionMode} onExecutionModeChange={setExecutionMode}
githubTrackingEnabled={githubTrackingEnabled} githubTrackingEnabled={githubTrackingEnabled}
onGithubTrackingEnabledChange={setGithubTrackingEnabled} onGithubTrackingEnabledChange={handleGithubTrackingEnabledChange}
githubRepoOverride={githubRepoOverride} githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride} onGithubRepoOverrideChange={setGithubRepoOverride}
onCreateSubmit={handleSubmit} 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 type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new" | "shared-group";
export interface EnabledWorkflowStepsChangeMeta { export interface EnabledWorkflowStepsChangeMeta {
optionalStepsAvailable: boolean; 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 = "──────────"; const PRESET_OPTION_SEPARATOR = "──────────";
@@ -87,9 +94,9 @@ export interface TaskFormProps {
priority?: TaskPriority; priority?: TaskPriority;
onPriorityChange?: (value: TaskPriority) => void; onPriorityChange?: (value: TaskPriority) => void;
executorModel: string; executorModel: string;
onExecutorModelChange: (value: string) => void; onExecutorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
validatorModel: string; validatorModel: string;
onValidatorModelChange: (value: string) => void; onValidatorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
planningModel?: string; planningModel?: string;
onPlanningModelChange?: (value: string) => void; onPlanningModelChange?: (value: string) => void;
thinkingLevel?: string; thinkingLevel?: string;
@@ -151,7 +158,7 @@ export interface TaskFormProps {
executionMode?: TaskExecutionModeSelection; executionMode?: TaskExecutionModeSelection;
onExecutionModeChange?: (value: TaskExecutionModeSelection) => void; onExecutionModeChange?: (value: TaskExecutionModeSelection) => void;
githubTrackingEnabled?: boolean; githubTrackingEnabled?: boolean;
onGithubTrackingEnabledChange?: (value: boolean) => void; onGithubTrackingEnabledChange?: (value: boolean, meta?: TaskFormValueChangeMeta) => void;
githubRepoOverride?: string; githubRepoOverride?: string;
onGithubRepoOverrideChange?: (value: string) => void; onGithubRepoOverrideChange?: (value: string) => void;
@@ -375,7 +382,7 @@ export function TaskForm({
// mid-flight when switching to "No workflow"), so the loading row never sticks. // mid-flight when switching to "No workflow"), so the loading row never sticks.
setOptionalStepsLoading(false); setOptionalStepsLoading(false);
if (isCreateOptionalStepPicker) { if (isCreateOptionalStepPicker) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false }); onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false, source: "initialization" });
} }
return; return;
} }
@@ -392,14 +399,14 @@ export function TaskForm({
const seededSteps = executionModeRef.current === "fast" const seededSteps = executionModeRef.current === "fast"
? [] ? []
: steps.filter((s) => s.defaultOn).map((s) => s.templateId); : steps.filter((s) => s.defaultOn).map((s) => s.templateId);
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 }); onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0, source: "initialization" });
} }
}) })
.catch(() => { .catch(() => {
if (cancelled) return; if (cancelled) return;
setOptionalSteps([]); setOptionalSteps([]);
if (isCreateOptionalStepPicker) { if (isCreateOptionalStepPicker) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false }); onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false, source: "initialization" });
} }
}) })
.finally(() => { .finally(() => {
@@ -421,7 +428,7 @@ export function TaskForm({
const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => { const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => {
onExecutionModeChange?.(nextMode); onExecutionModeChange?.(nextMode);
if (nextMode === "fast" && onWorkflowIdChange) { if (nextMode === "fast" && onWorkflowIdChange) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0 }); onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0, source: "user" });
} }
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, onWorkflowIdChange, optionalSteps.length]); }, [onEnabledWorkflowStepsChange, onExecutionModeChange, onWorkflowIdChange, optionalSteps.length]);
@@ -431,13 +438,19 @@ export function TaskForm({
const next = current.includes(templateId) const next = current.includes(templateId)
? current.filter((id) => id !== templateId) ? current.filter((id) => id !== templateId)
: [...current, templateId]; : [...current, templateId];
onEnabledWorkflowStepsChange?.(next, { optionalStepsAvailable: optionalSteps.length > 0 }); onEnabledWorkflowStepsChange?.(next, { optionalStepsAvailable: optionalSteps.length > 0, source: "user" });
}, },
[enabledWorkflowSteps, onEnabledWorkflowStepsChange, optionalSteps.length], [enabledWorkflowSteps, onEnabledWorkflowStepsChange, optionalSteps.length],
); );
const availablePresets = settings?.modelPresets || []; const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); 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 effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings);
const githubRepoOverrideTrimmed = (githubRepoOverride || "").trim(); const githubRepoOverrideTrimmed = (githubRepoOverride || "").trim();
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed); 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) // Auto-select preset by size (create mode only)
useEffect(() => { useEffect(() => {
if (mode !== "create" || !isActive || !settings?.autoSelectModelPreset) return; if (mode !== "create" || !isActive || !settings?.autoSelectModelPreset || hasUserSelectedModelRef.current) return;
const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets); const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets);
if (recommended) { if (recommended) {
const selection = applyPresetToSelection(recommended); const selection = applyPresetToSelection(recommended);
onSelectedPresetIdChange(recommended.id); onSelectedPresetIdChange(recommended.id);
onPresetModeChange("preset"); onPresetModeChange("preset");
onExecutorModelChange(selection.executorValue); onExecutorModelChange(selection.executorValue, { source: "initialization" });
onValidatorModelChange(selection.validatorValue); onValidatorModelChange(selection.validatorValue, { source: "initialization" });
} }
}, [isActive, settings, availablePresets, mode]); }, [isActive, settings, availablePresets, mode]);
@@ -483,7 +496,7 @@ export function TaskForm({
if (githubTrackingDefaultAppliedRef.current) return; if (githubTrackingDefaultAppliedRef.current) return;
if (!settings) return; if (!settings) return;
onGithubTrackingEnabledChange(settings.githubTrackingEnabledByDefault ?? false); onGithubTrackingEnabledChange(settings.githubTrackingEnabledByDefault ?? false, { source: "initialization" });
githubTrackingDefaultAppliedRef.current = true; githubTrackingDefaultAppliedRef.current = true;
}, [mode, isActive, settings, onGithubTrackingEnabledChange]); }, [mode, isActive, settings, onGithubTrackingEnabledChange]);
@@ -502,6 +515,7 @@ export function TaskForm({
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
githubTrackingDefaultAppliedRef.current = false; githubTrackingDefaultAppliedRef.current = false;
hasUserSelectedModelRef.current = false;
} }
}, [isActive]); }, [isActive]);
@@ -1480,11 +1494,12 @@ export function TaskForm({
value={presetMode === "preset" ? selectedPresetId : presetMode} value={presetMode === "preset" ? selectedPresetId : presetMode}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
hasUserSelectedModelRef.current = true;
if (value === "default") { if (value === "default") {
onPresetModeChange("default"); onPresetModeChange("default");
onSelectedPresetIdChange(""); onSelectedPresetIdChange("");
onExecutorModelChange(""); onExecutorModelChange("", { source: "user" });
onValidatorModelChange(""); onValidatorModelChange("", { source: "user" });
return; return;
} }
if (value === "custom") { if (value === "custom") {
@@ -1496,8 +1511,8 @@ export function TaskForm({
const selection = applyPresetToSelection(preset); const selection = applyPresetToSelection(preset);
onPresetModeChange("preset"); onPresetModeChange("preset");
onSelectedPresetIdChange(value); onSelectedPresetIdChange(value);
onExecutorModelChange(selection.executorValue); onExecutorModelChange(selection.executorValue, { source: "user" });
onValidatorModelChange(selection.validatorValue); onValidatorModelChange(selection.validatorValue, { source: "user" });
}} }}
disabled={disabled} disabled={disabled}
> >
@@ -1516,7 +1531,10 @@ export function TaskForm({
<button <button
type="button" type="button"
className="btn btn-sm" className="btn btn-sm"
onClick={() => onPresetModeChange("custom")} onClick={() => {
hasUserSelectedModelRef.current = true;
onPresetModeChange("custom");
}}
disabled={disabled} disabled={disabled}
> >
{t("taskForm.overridePreset", "Override")} {t("taskForm.overridePreset", "Override")}
@@ -1529,9 +1547,10 @@ export function TaskForm({
label={t("taskForm.executorModelLabel", "Executor Model")} label={t("taskForm.executorModelLabel", "Executor Model")}
value={executorModel} value={executorModel}
onChange={(value) => { onChange={(value) => {
hasUserSelectedModelRef.current = true;
onPresetModeChange("custom"); onPresetModeChange("custom");
onSelectedPresetIdChange(""); onSelectedPresetIdChange("");
onExecutorModelChange(value); onExecutorModelChange(value, { source: "user" });
}} }}
models={availableModels} models={availableModels}
disabled={disabled || presetMode === "preset"} disabled={disabled || presetMode === "preset"}
@@ -1551,9 +1570,10 @@ export function TaskForm({
label={t("taskForm.reviewerModelLabel", "Reviewer Model")} label={t("taskForm.reviewerModelLabel", "Reviewer Model")}
value={validatorModel} value={validatorModel}
onChange={(value) => { onChange={(value) => {
hasUserSelectedModelRef.current = true;
onPresetModeChange("custom"); onPresetModeChange("custom");
onSelectedPresetIdChange(""); onSelectedPresetIdChange("");
onValidatorModelChange(value); onValidatorModelChange(value, { source: "user" });
}} }}
models={availableModels} models={availableModels}
disabled={disabled || presetMode === "preset"} 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 () => { it("submits explicit empty optional steps when Fast is created before optional-step metadata loads", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api"); const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]); vi.mocked(fetchWorkflows).mockResolvedValue([WF]);