fix(FN-2555): restore execution mode updates from task edit flow

- Add explicit executionMode props to TaskForm edit controls and render a Standard/Fast selector in More Options
- Track editExecutionMode state in TaskDetailModal and reset it consistently when opening, canceling, or syncing task changes
- Include executionMode in update payload diffing so standard→fast sends "fast" and fast→standard clears to null
- Add regression tests for TaskForm selector behavior and TaskDetailModal update payload handling
This commit is contained in:
Fusion
2026-04-25 16:01:49 -07:00
committed by gsxdsm
parent 9d8852e26a
commit 655169725e
4 changed files with 155 additions and 5 deletions

View File

@@ -227,6 +227,10 @@ function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
: DEFAULT_TASK_PRIORITY; : DEFAULT_TASK_PRIORITY;
} }
function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" {
return executionMode === "fast" ? "fast" : "standard";
}
const DESCRIPTION_TRUNCATE_LENGTH = 200; const DESCRIPTION_TRUNCATE_LENGTH = 200;
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]); const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
@@ -342,6 +346,7 @@ export function TaskDetailModal({
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default"); const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
const [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined); const [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined);
const [editPriority, setEditPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY); const [editPriority, setEditPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [editSelectedPresetId, setEditSelectedPresetId] = useState(""); const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []); const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? ""); const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? "");
@@ -386,8 +391,9 @@ export function TaskDetailModal({
setEditSourceIssueRepository(task.sourceIssue?.repository ?? ""); setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
setEditSourceIssueUrl(task.sourceIssue?.url ?? ""); setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
setIsEditing(false); setIsEditing(false);
}, [task.id, task.title, task.description, task.sourceIssue]); }, [task.id, task.title, task.description, task.sourceIssue, task.executionMode]);
useEffect(() => { useEffect(() => {
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []); setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
@@ -554,6 +560,7 @@ export function TaskDetailModal({
setEditPresetMode(execModel || valModel || planModel ? "custom" : "default"); setEditPresetMode(execModel || valModel || planModel ? "custom" : "default");
setEditSelectedPresetId(""); setEditSelectedPresetId("");
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []); setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
setEditSourceIssueProvider(task.sourceIssue?.provider ?? ""); setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
setEditSourceIssueRepository(task.sourceIssue?.repository ?? ""); setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
@@ -573,9 +580,10 @@ export function TaskDetailModal({
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
setEditSourceIssueUrl(task.sourceIssue?.url ?? ""); setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
setEditPriority(normalizeTaskPriorityValue(task.priority)); setEditPriority(normalizeTaskPriorityValue(task.priority));
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setEditPendingImages([]); setEditPendingImages([]);
}, [task.title, task.description, task.dependencies, task.priority, editPendingImages]); }, [task.title, task.description, task.dependencies, task.priority, task.executionMode, editPendingImages]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@@ -633,6 +641,11 @@ export function TaskDetailModal({
updates.priority = editPriority; updates.priority = editPriority;
} }
const currentExecutionMode = normalizeExecutionModeValue(task.executionMode);
if (editExecutionMode !== currentExecutionMode) {
updates.executionMode = editExecutionMode === "fast" ? "fast" : null;
}
const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider); const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider);
const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository); const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository);
const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId); const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId);
@@ -718,7 +731,7 @@ export function TaskDetailModal({
setIsSaving(false); setIsSaving(false);
} }
} }
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editPriority, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, editPendingImages, addToast, projectId, onTaskUpdated]); }, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editPriority, editExecutionMode, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, editPendingImages, addToast, projectId, onTaskUpdated]);
const handleAutoSaveDescription = useCallback(async (description: string) => { const handleAutoSaveDescription = useCallback(async (description: string) => {
try { try {
@@ -1274,6 +1287,8 @@ export function TaskDetailModal({
onReviewLevelChange={setEditReviewLevel} onReviewLevelChange={setEditReviewLevel}
priority={editPriority} priority={editPriority}
onPriorityChange={setEditPriority} onPriorityChange={setEditPriority}
executionMode={editExecutionMode}
onExecutionModeChange={setEditExecutionMode}
renderBelowPrimary={( renderBelowPrimary={(
<div className="form-group detail-source-edit-group"> <div className="form-group detail-source-edit-group">
<label>Source Issue</label> <label>Source Issue</label>

View File

@@ -26,6 +26,8 @@ export interface PendingImage {
previewUrl: string; previewUrl: string;
} }
type TaskExecutionModeSelection = "standard" | "fast";
export interface TaskFormProps { export interface TaskFormProps {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -78,6 +80,8 @@ export interface TaskFormProps {
// Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full) // Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full)
reviewLevel?: number; reviewLevel?: number;
onReviewLevelChange?: (value: number) => void; onReviewLevelChange?: (value: number) => void;
executionMode?: TaskExecutionModeSelection;
onExecutionModeChange?: (value: TaskExecutionModeSelection) => void;
// AI-assisted creation callbacks (create mode only) // AI-assisted creation callbacks (create mode only)
onPlanningMode?: (initialPlan: string) => void; onPlanningMode?: (initialPlan: string) => void;
@@ -133,6 +137,8 @@ export function TaskForm({
autoExpandMoreOptionsOnSelection = true, autoExpandMoreOptionsOnSelection = true,
reviewLevel, reviewLevel,
onReviewLevelChange, onReviewLevelChange,
executionMode,
onExecutionModeChange,
}: TaskFormProps) { }: TaskFormProps) {
const hasInitialMoreOptions = const hasInitialMoreOptions =
(hideDependencies ? false : dependencies.length > 0) || (hideDependencies ? false : dependencies.length > 0) ||
@@ -144,7 +150,8 @@ export function TaskForm({
validatorModel !== "" || validatorModel !== "" ||
(planningModel || "") !== "" || (planningModel || "") !== "" ||
(thinkingLevel || "") !== "" || (thinkingLevel || "") !== "" ||
reviewLevel !== undefined; reviewLevel !== undefined ||
executionMode === "fast";
const [showDepDropdown, setShowDepDropdown] = useState(false); const [showDepDropdown, setShowDepDropdown] = useState(false);
const [showMoreOptions, setShowMoreOptions] = useState( const [showMoreOptions, setShowMoreOptions] = useState(
@@ -208,7 +215,8 @@ export function TaskForm({
validatorModel !== "" || validatorModel !== "" ||
(planningModel || "") !== "" || (planningModel || "") !== "" ||
(thinkingLevel || "") !== "" || (thinkingLevel || "") !== "" ||
reviewLevel !== undefined; reviewLevel !== undefined ||
executionMode === "fast";
// Auto-select preset by size (create mode only) // Auto-select preset by size (create mode only)
useEffect(() => { useEffect(() => {
@@ -876,6 +884,21 @@ export function TaskForm({
</select> </select>
</div> </div>
)} )}
{onExecutionModeChange && executionMode !== undefined && (
<div className="model-select-row">
<label htmlFor="task-execution-mode" className="model-select-label">Execution mode</label>
<select
id="task-execution-mode"
data-testid="task-form-execution-mode-select"
value={executionMode}
onChange={(e) => onExecutionModeChange(e.target.value as TaskExecutionModeSelection)}
disabled={disabled}
>
<option value="standard">Standard</option>
<option value="fast">Fast</option>
</select>
</div>
)}
{modelsLoading ? ( {modelsLoading ? (
<div className="model-selector-loading">Loading models</div> <div className="model-selector-loading">Loading models</div>
) : availableModels.length === 0 ? ( ) : availableModels.length === 0 ? (

View File

@@ -4764,6 +4764,83 @@ describe("TaskDetailModal", () => {
}); });
}); });
it("sends executionMode: \"fast\" when changed from standard to fast", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
});
it("sends executionMode: null when changed from fast to standard", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "standard" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
});
});
it("omits executionMode from update payload when unchanged", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
});
it("renders normalized priority in detail metadata", () => { it("renders normalized priority in detail metadata", () => {
render( render(
<TaskDetailModal <TaskDetailModal

View File

@@ -169,6 +169,41 @@ describe("TaskForm", () => {
expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
}); });
it("renders execution mode selector only when execution mode props are provided", () => {
const { rerender, props } = renderTaskForm();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.queryByTestId("task-form-execution-mode-select")).toBeNull();
rerender(
<TaskForm
{...props}
executionMode="standard"
onExecutionModeChange={vi.fn()}
/>,
);
const executionModeSelect = screen.getByTestId("task-form-execution-mode-select") as HTMLSelectElement;
expect(executionModeSelect).toBeTruthy();
const options = Array.from(executionModeSelect.options).map((option) => option.value);
expect(options).toEqual(["standard", "fast"]);
});
it("calls onExecutionModeChange when execution mode selection changes", () => {
const onExecutionModeChange = vi.fn();
renderTaskForm({
executionMode: "standard",
onExecutionModeChange,
});
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
expect(onExecutionModeChange).toHaveBeenCalledWith("fast");
});
it("renders priority select with default normal value when enabled", () => { it("renders priority select with default normal value when enabled", () => {
renderTaskForm({ onPriorityChange: vi.fn() }); renderTaskForm({ onPriorityChange: vi.fn() });