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:
@@ -227,6 +227,10 @@ function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
|
||||
: DEFAULT_TASK_PRIORITY;
|
||||
}
|
||||
|
||||
function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" {
|
||||
return executionMode === "fast" ? "fast" : "standard";
|
||||
}
|
||||
|
||||
const DESCRIPTION_TRUNCATE_LENGTH = 200;
|
||||
|
||||
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 [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [editPriority, setEditPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
|
||||
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
|
||||
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||
const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? "");
|
||||
@@ -386,8 +391,9 @@ export function TaskDetailModal({
|
||||
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
|
||||
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
|
||||
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
|
||||
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||
setIsEditing(false);
|
||||
}, [task.id, task.title, task.description, task.sourceIssue]);
|
||||
}, [task.id, task.title, task.description, task.sourceIssue, task.executionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
||||
@@ -554,6 +560,7 @@ export function TaskDetailModal({
|
||||
setEditPresetMode(execModel || valModel || planModel ? "custom" : "default");
|
||||
setEditSelectedPresetId("");
|
||||
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
|
||||
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
|
||||
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
|
||||
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
|
||||
@@ -573,9 +580,10 @@ export function TaskDetailModal({
|
||||
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
|
||||
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
|
||||
setEditPriority(normalizeTaskPriorityValue(task.priority));
|
||||
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
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 () => {
|
||||
setIsSaving(true);
|
||||
@@ -633,6 +641,11 @@ export function TaskDetailModal({
|
||||
updates.priority = editPriority;
|
||||
}
|
||||
|
||||
const currentExecutionMode = normalizeExecutionModeValue(task.executionMode);
|
||||
if (editExecutionMode !== currentExecutionMode) {
|
||||
updates.executionMode = editExecutionMode === "fast" ? "fast" : null;
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider);
|
||||
const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository);
|
||||
const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId);
|
||||
@@ -718,7 +731,7 @@ export function TaskDetailModal({
|
||||
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) => {
|
||||
try {
|
||||
@@ -1274,6 +1287,8 @@ export function TaskDetailModal({
|
||||
onReviewLevelChange={setEditReviewLevel}
|
||||
priority={editPriority}
|
||||
onPriorityChange={setEditPriority}
|
||||
executionMode={editExecutionMode}
|
||||
onExecutionModeChange={setEditExecutionMode}
|
||||
renderBelowPrimary={(
|
||||
<div className="form-group detail-source-edit-group">
|
||||
<label>Source Issue</label>
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface PendingImage {
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
type TaskExecutionModeSelection = "standard" | "fast";
|
||||
|
||||
export interface TaskFormProps {
|
||||
mode: "create" | "edit";
|
||||
|
||||
@@ -78,6 +80,8 @@ export interface TaskFormProps {
|
||||
// Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full)
|
||||
reviewLevel?: number;
|
||||
onReviewLevelChange?: (value: number) => void;
|
||||
executionMode?: TaskExecutionModeSelection;
|
||||
onExecutionModeChange?: (value: TaskExecutionModeSelection) => void;
|
||||
|
||||
// AI-assisted creation callbacks (create mode only)
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
@@ -133,6 +137,8 @@ export function TaskForm({
|
||||
autoExpandMoreOptionsOnSelection = true,
|
||||
reviewLevel,
|
||||
onReviewLevelChange,
|
||||
executionMode,
|
||||
onExecutionModeChange,
|
||||
}: TaskFormProps) {
|
||||
const hasInitialMoreOptions =
|
||||
(hideDependencies ? false : dependencies.length > 0) ||
|
||||
@@ -144,7 +150,8 @@ export function TaskForm({
|
||||
validatorModel !== "" ||
|
||||
(planningModel || "") !== "" ||
|
||||
(thinkingLevel || "") !== "" ||
|
||||
reviewLevel !== undefined;
|
||||
reviewLevel !== undefined ||
|
||||
executionMode === "fast";
|
||||
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(
|
||||
@@ -208,7 +215,8 @@ export function TaskForm({
|
||||
validatorModel !== "" ||
|
||||
(planningModel || "") !== "" ||
|
||||
(thinkingLevel || "") !== "" ||
|
||||
reviewLevel !== undefined;
|
||||
reviewLevel !== undefined ||
|
||||
executionMode === "fast";
|
||||
|
||||
// Auto-select preset by size (create mode only)
|
||||
useEffect(() => {
|
||||
@@ -876,6 +884,21 @@ export function TaskForm({
|
||||
</select>
|
||||
</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 ? (
|
||||
<div className="model-selector-loading">Loading models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
|
||||
@@ -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", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -169,6 +169,41 @@ describe("TaskForm", () => {
|
||||
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", () => {
|
||||
renderTaskForm({ onPriorityChange: vi.fn() });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user