FN-7341: replan tasks after execution mode changes
Execution-mode changes now confirm and rebuild task plans when active task state would be invalidated. - Require confirmation before changing Standard/Fast mode on todo and in-progress tasks. - Route confirmed execution-mode edits through the task spec rebuild path and close the modal with replanning feedback. - Cover inline and edit-mode execution-mode changes with dashboard tests and localized copy. Files changed: .changeset/fn-7341-execution-mode-replan.md | 7 + .../dashboard/app/components/TaskDetailModal.tsx | 70 +++++++- ...lModal.inline-editing-and-integrations.test.tsx | 181 ++++++++++++++++++--- .../__tests__/TaskDetailModal.test-helpers.ts | 1 + packages/i18n/locales/en/app.json | 3 + packages/i18n/locales/es/app.json | 5 +- packages/i18n/locales/fr/app.json | 5 +- packages/i18n/locales/ko/app.json | 5 +- packages/i18n/locales/zh-CN/app.json | 5 +- packages/i18n/locales/zh-TW/app.json | 5 +- packages/i18n/src/resources.d.ts | 3 + 11 files changed, 257 insertions(+), 33 deletions(-) Fusion-Task-Id: FN-7341 Fusion-Task-Lineage: 2b1e7ac9-81b4-438a-84d1-965da5ed84fb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7341-execution-mode-replan.md
Normal file
7
.changeset/fn-7341-execution-mode-replan.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Replan active tasks after confirming execution-mode changes.
|
||||||
|
category: fix
|
||||||
|
dev: Dashboard inline execution-mode changes on todo/in-progress tasks now confirm and call the spec rebuild path.
|
||||||
@@ -398,6 +398,14 @@ function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "sta
|
|||||||
return executionMode === "fast" ? "fast" : "standard";
|
return executionMode === "fast" ? "fast" : "standard";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requiresExecutionModeReplan(column: Task["column"]): boolean {
|
||||||
|
/*
|
||||||
|
FNXC:ExecutionModeReplan 2026-06-30-00:00:
|
||||||
|
Todo and in-progress tasks can already hold a generated plan or active execution context. Changing Standard/Fast mode invalidates that plan, so the dashboard must confirm the change and send the task back through the existing replanning path instead of silently patching executionMode in place.
|
||||||
|
*/
|
||||||
|
return column === "todo" || column === "in-progress";
|
||||||
|
}
|
||||||
|
|
||||||
interface ProvenanceDisplay {
|
interface ProvenanceDisplay {
|
||||||
label: string;
|
label: string;
|
||||||
parentTaskId?: string;
|
parentTaskId?: string;
|
||||||
@@ -896,6 +904,10 @@ export function TaskDetailContent({
|
|||||||
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
|
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
|
||||||
const [inlineNoCommitsExpected, setInlineNoCommitsExpected] = useState<boolean>(task.noCommitsExpected === true);
|
const [inlineNoCommitsExpected, setInlineNoCommitsExpected] = useState<boolean>(task.noCommitsExpected === true);
|
||||||
const [isSavingInlineNoCommitsExpected, setIsSavingInlineNoCommitsExpected] = useState(false);
|
const [isSavingInlineNoCommitsExpected, setIsSavingInlineNoCommitsExpected] = useState(false);
|
||||||
|
const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
onRequestClose?.();
|
||||||
|
}, [onRequestClose]);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const activeTaskIdRef = useRef(task.id);
|
const activeTaskIdRef = useRef(task.id);
|
||||||
|
|
||||||
@@ -1508,6 +1520,7 @@ export function TaskDetailContent({
|
|||||||
const [editAutoSaveStatus, setEditAutoSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
const [editAutoSaveStatus, setEditAutoSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
const editAutoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const editAutoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const editAutoSaveRevisionRef = useRef(0);
|
const editAutoSaveRevisionRef = useRef(0);
|
||||||
|
const editSaveTriggeredReplanRef = useRef(false);
|
||||||
|
|
||||||
const buildEditUpdates = useCallback((includeDescription: boolean) => {
|
const buildEditUpdates = useCallback((includeDescription: boolean) => {
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
@@ -1601,20 +1614,47 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const replanAfterExecutionModeChange = Object.prototype.hasOwnProperty.call(updates, "executionMode") && requiresExecutionModeReplan(task.column);
|
||||||
|
if (replanAfterExecutionModeChange && !includeDescription) {
|
||||||
|
delete updates.executionMode;
|
||||||
|
}
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (replanAfterExecutionModeChange && includeDescription) {
|
||||||
|
const nextMode = normalizeExecutionModeValue(updates.executionMode as Task["executionMode"]);
|
||||||
|
const shouldChangeMode = await confirm({
|
||||||
|
title: t("taskDetail.executionMode.replanTitle", "Change execution mode and replan?"),
|
||||||
|
message: t("taskDetail.executionMode.replanMessage", "Changing execution mode for this task will move it back to Planning so Fusion can rebuild the plan for {{mode}} mode.", { mode: nextMode }),
|
||||||
|
});
|
||||||
|
if (!shouldChangeMode) {
|
||||||
|
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const revision = ++editAutoSaveRevisionRef.current;
|
const revision = ++editAutoSaveRevisionRef.current;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setEditAutoSaveStatus("saving");
|
setEditAutoSaveStatus("saving");
|
||||||
try {
|
try {
|
||||||
const updatedTask = await updateTask(task.id, updates as never, projectId);
|
const updatedTask = await updateTask(task.id, updates as never, projectId);
|
||||||
if (revision !== editAutoSaveRevisionRef.current) return;
|
if (revision !== editAutoSaveRevisionRef.current) return;
|
||||||
|
if (replanAfterExecutionModeChange && includeDescription) {
|
||||||
|
const normalizedUpdatedMode = normalizeExecutionModeValue(updatedTask.executionMode);
|
||||||
|
await rebuildTaskSpec(task.id, projectId);
|
||||||
|
editSaveTriggeredReplanRef.current = true;
|
||||||
|
setEditAutoSaveStatus("saved");
|
||||||
|
requestClose();
|
||||||
|
addToast(t("taskDetail.executionMode.replanning", "Execution mode updated to {{mode}} — {{id}} returned to Planning for replanning", { mode: normalizedUpdatedMode, id: task.id }), "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
onTaskUpdated?.(updatedTask);
|
onTaskUpdated?.(updatedTask);
|
||||||
setEditAutoSaveStatus("saved");
|
setEditAutoSaveStatus("saved");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (revision === editAutoSaveRevisionRef.current) {
|
if (revision === editAutoSaveRevisionRef.current) {
|
||||||
|
if (replanAfterExecutionModeChange) {
|
||||||
|
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
|
||||||
|
}
|
||||||
setEditAutoSaveStatus("error");
|
setEditAutoSaveStatus("error");
|
||||||
addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error");
|
addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error");
|
||||||
}
|
}
|
||||||
@@ -1624,15 +1664,16 @@ export function TaskDetailContent({
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [addToast, buildEditUpdates, onTaskUpdated, projectId, task.id]);
|
}, [addToast, buildEditUpdates, confirm, onTaskUpdated, projectId, requestClose, task.column, task.executionMode, task.id]);
|
||||||
|
|
||||||
const handleAutoSaveDescription = useCallback(async (_description: string) => {
|
const handleAutoSaveDescription = useCallback(async (_description: string) => {
|
||||||
await persistEditChanges(true);
|
await persistEditChanges(true);
|
||||||
}, [persistEditChanges]);
|
}, [persistEditChanges]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
editSaveTriggeredReplanRef.current = false;
|
||||||
const didSave = await persistEditChanges(true);
|
const didSave = await persistEditChanges(true);
|
||||||
if (!didSave) {
|
if (!didSave || editSaveTriggeredReplanRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addToast(t("taskDetail.updateSuccess", "Updated {{id}}", { id: task.id }), "success");
|
addToast(t("taskDetail.updateSuccess", "Updated {{id}}", { id: task.id }), "success");
|
||||||
@@ -1710,6 +1751,18 @@ export function TaskDetailContent({
|
|||||||
const currentMode = normalizeExecutionModeValue(task.executionMode);
|
const currentMode = normalizeExecutionModeValue(task.executionMode);
|
||||||
const nextMode = currentMode === "fast" ? "standard" : "fast";
|
const nextMode = currentMode === "fast" ? "standard" : "fast";
|
||||||
const previousMode = inlineExecutionMode;
|
const previousMode = inlineExecutionMode;
|
||||||
|
const shouldReplan = requiresExecutionModeReplan(task.column);
|
||||||
|
|
||||||
|
if (shouldReplan) {
|
||||||
|
const shouldChangeMode = await confirm({
|
||||||
|
title: t("taskDetail.executionMode.replanTitle", "Change execution mode and replan?"),
|
||||||
|
message: t("taskDetail.executionMode.replanMessage", "Changing execution mode for this task will move it back to Planning so Fusion can rebuild the plan for {{mode}} mode.", { mode: nextMode }),
|
||||||
|
});
|
||||||
|
if (!shouldChangeMode) {
|
||||||
|
setInlineExecutionMode(previousMode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setInlineExecutionMode(nextMode);
|
setInlineExecutionMode(nextMode);
|
||||||
setIsSavingInlineExecutionMode(true);
|
setIsSavingInlineExecutionMode(true);
|
||||||
@@ -1717,6 +1770,12 @@ export function TaskDetailContent({
|
|||||||
try {
|
try {
|
||||||
const updatedTask = await updateTask(task.id, { executionMode: nextMode === "fast" ? "fast" : null }, projectId);
|
const updatedTask = await updateTask(task.id, { executionMode: nextMode === "fast" ? "fast" : null }, projectId);
|
||||||
const normalizedUpdatedMode = normalizeExecutionModeValue(updatedTask.executionMode);
|
const normalizedUpdatedMode = normalizeExecutionModeValue(updatedTask.executionMode);
|
||||||
|
if (shouldReplan) {
|
||||||
|
await rebuildTaskSpec(task.id, projectId);
|
||||||
|
requestClose();
|
||||||
|
addToast(t("taskDetail.executionMode.replanning", "Execution mode updated to {{mode}} — {{id}} returned to Planning for replanning", { mode: normalizedUpdatedMode, id: task.id }), "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setInlineExecutionMode(normalizedUpdatedMode);
|
setInlineExecutionMode(normalizedUpdatedMode);
|
||||||
onTaskUpdated?.(updatedTask);
|
onTaskUpdated?.(updatedTask);
|
||||||
addToast(t("taskDetail.executionMode.updated", "Execution mode updated to {{mode}}", { mode: normalizedUpdatedMode }), "success");
|
addToast(t("taskDetail.executionMode.updated", "Execution mode updated to {{mode}}", { mode: normalizedUpdatedMode }), "success");
|
||||||
@@ -1728,7 +1787,7 @@ export function TaskDetailContent({
|
|||||||
setIsSavingInlineExecutionMode(false);
|
setIsSavingInlineExecutionMode(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [task.id, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast]);
|
}, [task.id, task.column, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast, confirm, requestClose]);
|
||||||
|
|
||||||
const handleInlineNoCommitsExpectedToggle = useCallback(async () => {
|
const handleInlineNoCommitsExpectedToggle = useCallback(async () => {
|
||||||
const nextValue = !inlineNoCommitsExpected;
|
const nextValue = !inlineNoCommitsExpected;
|
||||||
@@ -1775,7 +1834,6 @@ export function TaskDetailContent({
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { nodes } = useNodes();
|
const { nodes } = useNodes();
|
||||||
const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
|
|
||||||
|
|
||||||
const handleUnlinkGithubIssue = useCallback(async () => {
|
const handleUnlinkGithubIssue = useCallback(async () => {
|
||||||
if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return;
|
if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return;
|
||||||
@@ -1811,10 +1869,6 @@ export function TaskDetailContent({
|
|||||||
activeTab === "chat" && activitySegment === "raw-logs",
|
activeTab === "chat" && activitySegment === "raw-logs",
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
const requestClose = useCallback(() => {
|
|
||||||
onRequestClose?.();
|
|
||||||
}, [onRequestClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (embedded) return;
|
if (embedded) return;
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -892,6 +892,40 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("confirms and replans when edit-mode executionMode changes on a todo task", async () => {
|
||||||
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
|
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", title: "Test", description: "Desc", executionMode: null }) as Task);
|
||||||
|
mockRebuild.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "triage", status: "needs-replan", executionMode: null }) as Task);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({ id: "FN-001", column: "todo", 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(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
title: "Change execution mode and replan?",
|
||||||
|
}));
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
|
||||||
|
expect(mockRebuild).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
|
});
|
||||||
|
expect(mockUpdate.mock.invocationCallOrder[0]).toBeLessThan(mockRebuild.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
it("omits executionMode from update payload when unchanged", async () => {
|
it("omits executionMode from update payload when unchanged", async () => {
|
||||||
const { updateTask } = await import("../../api");
|
const { updateTask } = await import("../../api");
|
||||||
const mockUpdate = vi.mocked(updateTask);
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
@@ -940,13 +974,13 @@ describe("TaskDetailModal", () => {
|
|||||||
const { updateTask } = await import("../../api");
|
const { updateTask } = await import("../../api");
|
||||||
const mockUpdate = vi.mocked(updateTask);
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
mockUpdate
|
mockUpdate
|
||||||
.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", priority: "urgent", executionMode: "standard" }) as Task)
|
.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "triage", priority: "urgent", executionMode: "standard" }) as Task)
|
||||||
.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", priority: "urgent", executionMode: "fast" }) as Task);
|
.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "triage", priority: "urgent", executionMode: "fast" }) as Task);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ id: "FN-001", column: "todo", priority: "high", executionMode: "standard" })}
|
task={makeTask({ id: "FN-001", column: "triage", priority: "high", executionMode: "standard" })}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
onMoveTask={noopMove}
|
onMoveTask={noopMove}
|
||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
@@ -1080,13 +1114,14 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
|
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles inline execution mode from standard to fast", async () => {
|
it("prompts before replanning a todo task when changing inline execution mode from standard to fast", async () => {
|
||||||
const { updateTask } = await import("../../api");
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
const mockUpdate = vi.mocked(updateTask);
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
const onTaskUpdated = vi.fn();
|
const onTaskUpdated = vi.fn();
|
||||||
const updatedTask = makeTask({ id: "FN-001", column: "todo", executionMode: "fast" });
|
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", executionMode: "fast" }) as Task);
|
||||||
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
|
mockRebuild.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "triage", status: "needs-replan", executionMode: "fast" }) as Task);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -1102,23 +1137,58 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggle = screen.getByRole("button", { name: "Execution mode: standard" });
|
fireEvent.click(screen.getByRole("button", { name: "Execution mode: standard" }));
|
||||||
fireEvent.click(toggle);
|
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
title: "Change execution mode and replan?",
|
||||||
|
message: "Changing execution mode for this task will move it back to Planning so Fusion can rebuild the plan for fast mode.",
|
||||||
|
}));
|
||||||
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
|
||||||
|
expect(mockRebuild).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
});
|
});
|
||||||
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
expect(mockUpdate.mock.invocationCallOrder[0]).toBeLessThan(mockRebuild.mock.invocationCallOrder[0]);
|
||||||
expect(addToast).toHaveBeenCalledWith("Execution mode updated to fast", "success");
|
expect(onTaskUpdated).not.toHaveBeenCalled();
|
||||||
await waitFor(() => {
|
expect(addToast).toHaveBeenCalledWith("Execution mode updated to fast — FN-001 returned to Planning for replanning", "info");
|
||||||
expect(screen.getByRole("button", { name: "Execution mode: fast" })).toHaveAttribute("aria-pressed", "true");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles inline execution mode from fast to standard", async () => {
|
it("cancels a todo inline execution mode change before update or replan", async () => {
|
||||||
const { updateTask } = await import("../../api");
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
const mockUpdate = vi.mocked(updateTask);
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
|
mockConfirm.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Execution mode: standard" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockConfirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(mockRebuild).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts and replans a todo task when changing inline execution mode from fast to standard", async () => {
|
||||||
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", executionMode: null }) as Task);
|
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", executionMode: null }) as Task);
|
||||||
|
mockRebuild.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "triage", status: "needs-replan", executionMode: null }) as Task);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -1136,18 +1206,88 @@ describe("TaskDetailModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Execution mode: fast" }));
|
fireEvent.click(screen.getByRole("button", { name: "Execution mode: fast" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
message: "Changing execution mode for this task will move it back to Planning so Fusion can rebuild the plan for standard mode.",
|
||||||
|
}));
|
||||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
|
||||||
|
expect(mockRebuild).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
});
|
});
|
||||||
|
expect(mockUpdate.mock.invocationCallOrder[0]).toBeLessThan(mockRebuild.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts and replans an in-progress task when changing inline execution mode", async () => {
|
||||||
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
|
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "in-progress", executionMode: "fast" }) as Task);
|
||||||
|
mockRebuild.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "triage", status: "needs-replan", executionMode: "fast" }) as Task);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({ id: "FN-001", column: "in-progress", executionMode: "standard" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Execution mode: standard" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
|
expect(mockConfirm).toHaveBeenCalled();
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
|
||||||
|
expect(mockRebuild).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reverts inline execution mode when save fails", async () => {
|
it("updates triage inline execution mode without prompting or replanning", async () => {
|
||||||
const { updateTask } = await import("../../api");
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
const mockUpdate = vi.mocked(updateTask);
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
mockUpdate.mockRejectedValueOnce(new Error("Request failed"));
|
const onTaskUpdated = vi.fn();
|
||||||
|
const updatedTask = makeTask({ id: "FN-001", column: "triage", executionMode: "fast" });
|
||||||
|
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({ id: "FN-001", column: "triage", executionMode: "standard" })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onTaskUpdated={onTaskUpdated}
|
||||||
|
addToast={addToast}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Execution mode: standard" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
|
||||||
|
});
|
||||||
|
expect(mockConfirm).not.toHaveBeenCalled();
|
||||||
|
expect(mockRebuild).not.toHaveBeenCalled();
|
||||||
|
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Execution mode updated to fast", "success");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "Execution mode: fast" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts inline execution mode when active-task replan fails after update", async () => {
|
||||||
|
const { updateTask, rebuildTaskSpec } = await import("../../api");
|
||||||
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
|
const mockRebuild = vi.mocked(rebuildTaskSpec);
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", executionMode: "fast" }) as Task);
|
||||||
|
mockRebuild.mockRejectedValueOnce(new Error("Replan failed"));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -1166,11 +1306,12 @@ describe("TaskDetailModal", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
|
||||||
|
expect(mockRebuild).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
|
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
|
||||||
});
|
});
|
||||||
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
|
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Replan failed", "error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables inline execution mode toggle while save is in-flight", async () => {
|
it("disables inline execution mode toggle while save is in-flight", async () => {
|
||||||
@@ -1183,7 +1324,7 @@ describe("TaskDetailModal", () => {
|
|||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
initialTab="definition"
|
initialTab="definition"
|
||||||
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
|
task={makeTask({ id: "FN-001", column: "triage", executionMode: "standard" })}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
onMoveTask={noopMove}
|
onMoveTask={noopMove}
|
||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
|
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
|
||||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
requestSpecRevision: vi.fn().mockResolvedValue({}),
|
requestSpecRevision: vi.fn().mockResolvedValue({}),
|
||||||
|
rebuildTaskSpec: vi.fn().mockResolvedValue(makeTask({ column: "triage", status: "needs-replan" })),
|
||||||
approvePlan: vi.fn().mockResolvedValue({}),
|
approvePlan: vi.fn().mockResolvedValue({}),
|
||||||
rejectPlan: vi.fn().mockResolvedValue({}),
|
rejectPlan: vi.fn().mockResolvedValue({}),
|
||||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||||
|
|||||||
@@ -7306,6 +7306,9 @@
|
|||||||
"executionMode": {
|
"executionMode": {
|
||||||
"ariaLabel": "Execution mode: {{mode}}",
|
"ariaLabel": "Execution mode: {{mode}}",
|
||||||
"fast": "Fast",
|
"fast": "Fast",
|
||||||
|
"replanMessage": "Changing execution mode for this task will move it back to Planning so Fusion can rebuild the plan for {{mode}} mode.",
|
||||||
|
"replanTitle": "Change execution mode and replan?",
|
||||||
|
"replanning": "Execution mode updated to {{mode}} — {{id}} returned to Planning for replanning",
|
||||||
"standard": "Standard",
|
"standard": "Standard",
|
||||||
"updated": "Execution mode updated to {{mode}}"
|
"updated": "Execution mode updated to {{mode}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7297,7 +7297,10 @@
|
|||||||
"ariaLabel": "Modo de ejecución: {{mode}}",
|
"ariaLabel": "Modo de ejecución: {{mode}}",
|
||||||
"fast": "Rápido",
|
"fast": "Rápido",
|
||||||
"standard": "Estándar",
|
"standard": "Estándar",
|
||||||
"updated": "Modo de ejecución actualizado a {{mode}}"
|
"updated": "Modo de ejecución actualizado a {{mode}}",
|
||||||
|
"replanMessage": "Cambiar el modo de ejecución de esta tarea la devolverá a Planificación para que Fusion reconstruya el plan para el modo {{mode}}.",
|
||||||
|
"replanTitle": "¿Cambiar el modo de ejecución y replanificar?",
|
||||||
|
"replanning": "Modo de ejecución actualizado a {{mode}} — {{id}} volvió a Planificación para replanificación"
|
||||||
},
|
},
|
||||||
"executionModeFast": "Rápido",
|
"executionModeFast": "Rápido",
|
||||||
"executionModeStandard": "Estándar",
|
"executionModeStandard": "Estándar",
|
||||||
|
|||||||
@@ -7297,7 +7297,10 @@
|
|||||||
"ariaLabel": "Mode d'exécution : {{mode}}",
|
"ariaLabel": "Mode d'exécution : {{mode}}",
|
||||||
"fast": "Rapide",
|
"fast": "Rapide",
|
||||||
"standard": "Standard",
|
"standard": "Standard",
|
||||||
"updated": "Mode d'exécution mis à jour : {{mode}}"
|
"updated": "Mode d'exécution mis à jour : {{mode}}",
|
||||||
|
"replanMessage": "Changer le mode d'exécution de cette tâche la renverra en planification afin que Fusion reconstruise le plan pour le mode {{mode}}.",
|
||||||
|
"replanTitle": "Changer le mode d'exécution et replanifier ?",
|
||||||
|
"replanning": "Mode d'exécution mis à jour vers {{mode}} — {{id}} est revenue en planification pour replanification"
|
||||||
},
|
},
|
||||||
"executionModeFast": "Rapide",
|
"executionModeFast": "Rapide",
|
||||||
"executionModeStandard": "Standard",
|
"executionModeStandard": "Standard",
|
||||||
|
|||||||
@@ -7297,7 +7297,10 @@
|
|||||||
"ariaLabel": "실행 모드: {{mode}}",
|
"ariaLabel": "실행 모드: {{mode}}",
|
||||||
"fast": "빠름",
|
"fast": "빠름",
|
||||||
"standard": "표준",
|
"standard": "표준",
|
||||||
"updated": "실행 모드가 {{mode}}로 업데이트되었습니다"
|
"updated": "실행 모드가 {{mode}}로 업데이트되었습니다",
|
||||||
|
"replanMessage": "이 작업의 실행 모드를 변경하면 Fusion이 {{mode}} 모드용 계획을 다시 만들 수 있도록 작업이 계획으로 돌아갑니다.",
|
||||||
|
"replanTitle": "실행 모드를 변경하고 다시 계획할까요?",
|
||||||
|
"replanning": "실행 모드가 {{mode}}로 업데이트되었습니다 — {{id}}가 재계획을 위해 계획으로 돌아갔습니다"
|
||||||
},
|
},
|
||||||
"executionModeFast": "빠름",
|
"executionModeFast": "빠름",
|
||||||
"executionModeStandard": "표준",
|
"executionModeStandard": "표준",
|
||||||
|
|||||||
@@ -7297,7 +7297,10 @@
|
|||||||
"ariaLabel": "执行模式:{{mode}}",
|
"ariaLabel": "执行模式:{{mode}}",
|
||||||
"fast": "快速",
|
"fast": "快速",
|
||||||
"standard": "标准",
|
"standard": "标准",
|
||||||
"updated": "执行模式已更新为 {{mode}}"
|
"updated": "执行模式已更新为 {{mode}}",
|
||||||
|
"replanMessage": "更改此任务的执行模式会将其移回规划,以便 Fusion 可以为 {{mode}} 模式重新生成计划。",
|
||||||
|
"replanTitle": "更改执行模式并重新规划?",
|
||||||
|
"replanning": "执行模式已更新为 {{mode}} — {{id}} 已返回规划以重新规划"
|
||||||
},
|
},
|
||||||
"executionModeFast": "快速",
|
"executionModeFast": "快速",
|
||||||
"executionModeStandard": "标准",
|
"executionModeStandard": "标准",
|
||||||
|
|||||||
@@ -7297,7 +7297,10 @@
|
|||||||
"ariaLabel": "執行模式:{{mode}}",
|
"ariaLabel": "執行模式:{{mode}}",
|
||||||
"fast": "快速",
|
"fast": "快速",
|
||||||
"standard": "標準",
|
"standard": "標準",
|
||||||
"updated": "執行模式已更新為 {{mode}}"
|
"updated": "執行模式已更新為 {{mode}}",
|
||||||
|
"replanMessage": "變更此任務的執行模式會將其移回規劃,讓 Fusion 可以為 {{mode}} 模式重新產生計畫。",
|
||||||
|
"replanTitle": "變更執行模式並重新規劃?",
|
||||||
|
"replanning": "執行模式已更新為 {{mode}} — {{id}} 已返回規劃以重新規劃"
|
||||||
},
|
},
|
||||||
"executionModeFast": "快速",
|
"executionModeFast": "快速",
|
||||||
"executionModeStandard": "標準",
|
"executionModeStandard": "標準",
|
||||||
|
|||||||
3
packages/i18n/src/resources.d.ts
vendored
3
packages/i18n/src/resources.d.ts
vendored
@@ -7345,6 +7345,9 @@ export default interface Resources {
|
|||||||
"executionMode": {
|
"executionMode": {
|
||||||
"ariaLabel": "Execution mode: {{mode}}",
|
"ariaLabel": "Execution mode: {{mode}}",
|
||||||
"fast": "Fast",
|
"fast": "Fast",
|
||||||
|
"replanMessage": "Changing execution mode for this task will move it back to Planning so Fusion can rebuild the plan for {{mode}} mode.",
|
||||||
|
"replanTitle": "Change execution mode and replan?",
|
||||||
|
"replanning": "Execution mode updated to {{mode}} — {{id}} returned to Planning for replanning",
|
||||||
"standard": "Standard",
|
"standard": "Standard",
|
||||||
"updated": "Execution mode updated to {{mode}}"
|
"updated": "Execution mode updated to {{mode}}"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user