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 { 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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user