From f3a69497dc1db7ff2b42c85af0ebfcb897b0ed69 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 18:43:29 -0700 Subject: [PATCH] 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) --- .changeset/fn-8563-pristine-new-task-close.md | 7 + .../dashboard/app/components/NewTaskModal.tsx | 68 +++++++-- .../dashboard/app/components/TaskForm.tsx | 58 +++++--- .../__tests__/NewTaskModal.test.tsx | 134 ++++++++++++++++++ 4 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 .changeset/fn-8563-pristine-new-task-close.md diff --git a/.changeset/fn-8563-pristine-new-task-close.md b/.changeset/fn-8563-pristine-new-task-close.md new file mode 100644 index 0000000000..af61c8ac44 --- /dev/null +++ b/.changeset/fn-8563-pristine-new-task-close.md @@ -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. diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index f2f2943a3a..9b87f3a875 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -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([]); const [shouldSubmitEnabledWorkflowSteps, setShouldSubmitEnabledWorkflowSteps] = useState(false); + const [hasUserSelectedEnabledWorkflowSteps, setHasUserSelectedEnabledWorkflowSteps] = useState(false); const [reviewLevel, setReviewLevel] = useState(undefined); const [autoMerge, setAutoMerge] = useState(undefined); const [priority, setPriority] = useState(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} diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index b01f17b00a..41447c5536 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -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({