FN-7781: add optional workflow step toggles to task edit form

Enables editing a task's optional workflow steps directly from TaskForm edit mode, sourcing the step catalog from the resolved task workflow instead of re-seeding from defaultOn.

- TaskForm loads optional-step catalog from the task's resolved workflow when editing and exposes toggles for enabling/disabling optional steps
- TaskDetailModal passes through the additional workflow context needed for edit-mode step toggling
- Added changeset for @runfusion/fusion (minor)
- Updated docs/dashboard-guide.md to describe the new edit-mode workflow step behavior
- Added test coverage in TaskForm.test.tsx and TaskDetailModal.models-progress-workflow.test.tsx

Files changed:
 .changeset/fn-7781-edit-workflow-steps.md          |  7 ++
 docs/dashboard-guide.md                            |  5 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  6 ++
 packages/dashboard/app/components/TaskForm.tsx     | 76 +++++++++++++-----
 ...skDetailModal.models-progress-workflow.test.tsx | 90 ++++++++++++++++++++++
 .../app/components/__tests__/TaskForm.test.tsx     | 51 ++++++++++++
 6 files changed, 214 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-7781

Fusion-Task-Lineage: e44d9383-cfe7-4da1-9f20-8191211651cf

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 08:03:35 -07:00
parent 035caca6c8
commit 5729fe292c
6 changed files with 214 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let task edits toggle optional workflow steps directly.
category: feature
dev: TaskForm edit mode now loads optional-step catalogs from the resolved task workflow without defaultOn re-seeding.

View File

@@ -323,6 +323,9 @@ Workflows define how a task moves through planning, execution, review, workflow
<!-- FNXC:NewTaskWorkflowDropdown 2026-06-30-18:52: The full New Task dialog workflow picker now matches the icon-rich workflow identity used in Board/List selectors while preserving create-time workflowId semantics. -->
When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow.
<!-- FNXC:WorkflowOptionalSteps 2026-07-10-08:00: Operators can now toggle optional workflow steps from the task Edit form as well as the Workflow tab. Edit mode must load the task's resolved workflow catalog without re-seeding from defaultOn, preserving the task's persisted enabledWorkflowSteps until the operator explicitly toggles a step. -->
Optional workflow steps can be toggled from the task **Edit** form's **More options → Workflow Steps** control or from the task's **Workflow** tab. The edit form uses the task's resolved workflow and preserves the task's current stored selection when it opens; workflow-authored `defaultOn` values remain a create-time/runtime default, not an edit-form re-seed.
The workflow editor opens as a full-screen modal editor for inspecting built-ins and authoring custom workflows.
Navigation:
@@ -1235,7 +1238,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
- GitLab stale state means Fusion is displaying the last persisted GitLab metadata after a sync/import refresh could not confirm a newer state; no GitLab token or secret is stored on the task.
- GitLab comment and close/reopen actions use the configured GitLab REST API base URL for GitLab.com or self-managed instances. Group-imported issues are updated only when Fusion has the concrete project identity plus IID, and merge requests are closed/reopened only for GitLab-supported states; Fusion never auto-merges a GitLab merge request.
- Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained.
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. In task edit mode, **Workflow Steps** appears only when the task's resolved workflow exposes optional steps, so workflows without optional steps do not leave an empty button shell.
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
- The **Plan** tab shows the stored **Original prompt** above the generated `PROMPT.md` content, so the exact task prompt remains visible after planning. It is collapsed by default behind a chevron toggle; expanding it renders the prompt as Markdown (the same renderer used for the generated plan body). It stays read-only — editing or requesting AI revision still applies only to the generated plan.
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.

View File

@@ -1055,6 +1055,9 @@ export function TaskDetailContent({
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
const handleEditWorkflowStepsChange = useCallback((enabledWorkflowSteps: string[]) => {
setEditSelectedWorkflowSteps(enabledWorkflowSteps);
}, []);
const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? "");
const [editSourceIssueRepository, setEditSourceIssueRepository] = useState(task.sourceIssue?.repository ?? "");
const [editSourceIssueExternalId, setEditSourceIssueExternalId] = useState(task.sourceIssue?.externalIssueId ?? "");
@@ -3812,6 +3815,9 @@ export function TaskDetailContent({
onPresetModeChange={setEditPresetMode}
selectedPresetId={editSelectedPresetId}
onSelectedPresetIdChange={setEditSelectedPresetId}
optionalStepsWorkflowId={taskWorkflowBadge?.id}
enabledWorkflowSteps={editSelectedWorkflowSteps}
onEnabledWorkflowStepsChange={handleEditWorkflowStepsChange}
pendingImages={editPendingImages}
onImagesChange={setEditPendingImages}
tasks={tasks.filter((t) => t.id !== task.id)}

View File

@@ -113,13 +113,17 @@ export interface TaskFormProps {
// - `undefined` → inherit the project default (preselected + "(default)" badge).
// - `null` → "No workflow" (listed first).
// - `string` → a specific workflow id.
// The dropdown only renders when `onWorkflowIdChange` is provided (create mode);
// edit-mode workflow management lives in the task detail Workflow tab.
// The dropdown only renders when `onWorkflowIdChange` is provided (create mode).
selectedWorkflowId?: string | null;
onWorkflowIdChange?: (workflowId: string | null) => void;
/*
* FNXC:WorkflowOptionalSteps 2026-07-10-00:00:
* Edit mode needs the task's resolved workflow id to fetch the optional-step catalog without enabling workflow selection in the edit form. This id is catalog-only: create mode still resolves from selectedWorkflowId/default workflow and remains the only path that seeds defaultOn steps.
*/
optionalStepsWorkflowId?: string | null;
// Optional workflow steps the task can opt into. TaskForm fetches + seeds these
// from the selected workflow's `defaultOn` and lifts the enabled set to the
// parent (which puts it in the create payload). Only active in create mode.
// from the selected workflow's `defaultOn` in create mode, while edit mode only
// mutates the provided task-specific ids.
enabledWorkflowSteps?: string[];
onEnabledWorkflowStepsChange?: (ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => void;
@@ -215,6 +219,7 @@ export function TaskForm({
onSelectedPresetIdChange,
selectedWorkflowId,
onWorkflowIdChange,
optionalStepsWorkflowId,
enabledWorkflowSteps,
onEnabledWorkflowStepsChange,
pendingImages,
@@ -264,6 +269,7 @@ export function TaskForm({
(branch || "") !== "" ||
(baseBranch || "") !== "" ||
(nodeId || "") !== "" ||
(mode === "edit" && (enabledWorkflowSteps?.length ?? 0) > 0) ||
githubTrackingEnabled === true ||
(githubRepoOverride || "") !== "";
@@ -355,35 +361,44 @@ export function TaskForm({
selectedWorkflowId === null
? null
: (selectedWorkflowId ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null));
const resolvedOptionalWorkflowId = onWorkflowIdChange ? effectiveOptionalWorkflowId : (optionalStepsWorkflowId ?? null);
useEffect(() => {
if (!onWorkflowIdChange) return; // edit mode: optional steps are managed in the Workflow tab.
const isCreateOptionalStepPicker = Boolean(onWorkflowIdChange);
const isEditOptionalStepPicker = mode === "edit" && Boolean(optionalStepsWorkflowId);
if (!isCreateOptionalStepPicker && !isEditOptionalStepPicker) return;
let cancelled = false;
setOptionalSteps([]);
if (!effectiveOptionalWorkflowId) {
if (!resolvedOptionalWorkflowId) {
// Clear any in-flight loading state (a prior fetch may have been cancelled
// mid-flight when switching to "No workflow"), so the loading row never sticks.
setOptionalStepsLoading(false);
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
if (isCreateOptionalStepPicker) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
}
return;
}
setOptionalStepsLoading(true);
fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId)
fetchWorkflowOptionalSteps(resolvedOptionalWorkflowId, projectId)
.then((steps) => {
if (cancelled) return;
setOptionalSteps(steps);
/*
FNXC:FastOptionalSteps 2026-06-30-10:25:
Optional-step fetches race user mode changes. When the latest mode is Fast, seed an explicit empty set after loading instead of defaultOn ids so async workflow metadata cannot re-enable optional gates the operator has not manually reselected.
*/
const seededSteps = executionModeRef.current === "fast"
? []
: steps.filter((s) => s.defaultOn).map((s) => s.templateId);
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 });
if (isCreateOptionalStepPicker) {
/*
FNXC:FastOptionalSteps 2026-06-30-10:25:
Optional-step fetches race user mode changes. When the latest mode is Fast, seed an explicit empty set after loading instead of defaultOn ids so async workflow metadata cannot re-enable optional gates the operator has not manually reselected.
*/
const seededSteps = executionModeRef.current === "fast"
? []
: steps.filter((s) => s.defaultOn).map((s) => s.templateId);
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 });
}
})
.catch(() => {
if (cancelled) return;
setOptionalSteps([]);
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
if (isCreateOptionalStepPicker) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
}
})
.finally(() => {
if (!cancelled) setOptionalStepsLoading(false);
@@ -394,7 +409,7 @@ export function TaskForm({
// onEnabledWorkflowStepsChange intentionally omitted from deps: a new identity
// each render must not re-trigger the fetch/re-seed (would clobber user toggles).
// Callers must pass a stable callback (NewTaskModal passes a useState setter).
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
}, [onWorkflowIdChange, optionalStepsWorkflowId, mode, resolvedOptionalWorkflowId, projectId]);
const enabledOptionalStepIds = enabledWorkflowSteps ?? [];
/*
@@ -403,10 +418,10 @@ export function TaskForm({
*/
const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => {
onExecutionModeChange?.(nextMode);
if (nextMode === "fast") {
if (nextMode === "fast" && onWorkflowIdChange) {
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0 });
}
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, optionalSteps.length]);
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, onWorkflowIdChange, optionalSteps.length]);
const toggleOptionalStep = useCallback(
(templateId: string) => {
@@ -440,6 +455,7 @@ export function TaskForm({
(branch || "") !== "" ||
(baseBranch || "") !== "" ||
(nodeId || "") !== "" ||
(mode === "edit" && (enabledWorkflowSteps?.length ?? 0) > 0) ||
githubTrackingEnabled === true ||
(githubRepoOverride || "") !== "";
@@ -1743,6 +1759,26 @@ export function TaskForm({
</div>
)}
{mode === "edit" && onEnabledWorkflowStepsChange && (optionalStepsLoading || optionalSteps.length > 0) && (
<div className="form-group" data-testid="task-form-edit-workflow-steps-group">
{/* FNXC:WorkflowOptionalSteps 2026-07-10-00:18: Edit-mode Workflow Steps belongs in More options immediately before GitHub Tracking so shared edit/create forms keep the documented advanced-section order while avoiding an empty button shell for workflows with no optional steps. */}
<label>{t("taskForm.workflowStepsLabel", "Workflow Steps")}</label>
{optionalStepsLoading ? (
<small className="workflow-optional-steps-loading" data-testid="task-edit-optional-steps-loading">
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
</small>
) : (
<WorkflowOptionalStepsDropdown
steps={optionalSteps}
enabledIds={enabledOptionalStepIds}
onToggle={toggleOptionalStep}
disabled={disabled}
triggerTestId="task-form-edit-optional-steps"
/>
)}
</div>
)}
{(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
<div className="form-group" data-testid="task-form-github-tracking">
<label>{t("taskForm.githubTrackingLabel", "GitHub Tracking")}</label>

View File

@@ -1060,6 +1060,96 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Workflow")).toBeTruthy();
});
it("saves workflow step toggles from task edit mode", async () => {
const { fetchBoardWorkflows, fetchWorkflowOptionalSteps, updateTask } = await import("../../api");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "wf-edit",
workflows: [{ id: "wf-edit", name: "Edit Workflow" }],
taskWorkflowIds: { "FN-099": "wf-edit" },
} as any);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValueOnce([
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
] as any);
vi.mocked(updateTask).mockResolvedValueOnce(makeTask({ enabledWorkflowSteps: ["browser-verification", "code-review"] }) as any);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "todo" as any, enabledWorkflowSteps: ["browser-verification"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit task" }));
const trigger = await screen.findByTestId("task-form-edit-optional-steps");
expect(trigger).toHaveTextContent("Steps: 1 selected");
fireEvent.click(trigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-code-review"));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith(
"FN-099",
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification", "code-review"] }),
undefined,
);
});
});
it("does not reset enabled workflow steps when saving unrelated edit fields", async () => {
const { fetchBoardWorkflows, fetchWorkflowOptionalSteps, updateTask } = await import("../../api");
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "wf-edit",
workflows: [{ id: "wf-edit", name: "Edit Workflow" }],
taskWorkflowIds: { "FN-099": "wf-edit" },
} as any);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValueOnce([
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
] as any);
vi.mocked(updateTask).mockResolvedValueOnce(makeTask({ title: "Edited title", enabledWorkflowSteps: ["browser-verification"] }) as any);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "todo" as any, title: "Original title", enabledWorkflowSteps: ["browser-verification"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit task" }));
await screen.findByTestId("task-form-edit-optional-steps");
const titleInput = screen.getByRole("textbox", { name: /Title/i });
fireEvent.change(titleInput, { target: { value: "Edited title" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith(
"FN-099",
expect.not.objectContaining({ enabledWorkflowSteps: expect.anything() }),
undefined,
);
});
expect(updateTask).toHaveBeenCalledWith(
"FN-099",
expect.objectContaining({ title: "Edited title" }),
undefined,
);
});
it("switches to Workflow tab and calls fetchWorkflowResults", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);

View File

@@ -332,6 +332,57 @@ describe("TaskForm", () => {
expect(onEnabledWorkflowStepsChange).toHaveBeenLastCalledWith([], expect.objectContaining({ optionalStepsAvailable: true }));
});
it("renders edit-mode workflow steps without clobbering existing task selection", async () => {
const { fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
] as any);
const onEnabledWorkflowStepsChange = vi.fn();
renderTaskForm({
mode: "edit",
onWorkflowIdChange: undefined,
selectedWorkflowId: undefined,
optionalStepsWorkflowId: "wf-edit",
enabledWorkflowSteps: ["browser-verification"],
onEnabledWorkflowStepsChange,
});
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-edit", undefined));
const trigger = await screen.findByTestId("task-form-edit-optional-steps");
expect(trigger).toHaveTextContent("Steps: 1 selected");
expect(onEnabledWorkflowStepsChange).not.toHaveBeenCalled();
fireEvent.click(trigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-code-review"));
expect(onEnabledWorkflowStepsChange).toHaveBeenCalledWith(
["browser-verification", "code-review"],
expect.objectContaining({ optionalStepsAvailable: true }),
);
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
});
it("renders no edit-mode workflow steps shell when the workflow has no optional steps", async () => {
const { fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([] as any);
renderTaskForm({
mode: "edit",
onWorkflowIdChange: undefined,
selectedWorkflowId: undefined,
optionalStepsWorkflowId: "wf-empty",
enabledWorkflowSteps: [],
onEnabledWorkflowStepsChange: vi.fn(),
});
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-empty", undefined));
await waitFor(() => expect(screen.queryByTestId("task-edit-optional-steps-loading")).toBeNull());
expect(screen.queryByTestId("task-form-edit-workflow-steps-group")).toBeNull();
expect(screen.queryByTestId("task-form-edit-optional-steps")).toBeNull();
});
it("calls onExecutionModeChange when execution mode selection changes", () => {
const onExecutionModeChange = vi.fn();