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:
7
.changeset/fn-8563-pristine-new-task-close.md
Normal file
7
.changeset/fn-8563-pristine-new-task-close.md
Normal 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.
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user