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;
|
: 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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user