From 5729fe292c65cf5a34acff9caa21681e0d3bea2b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 08:03:35 -0700 Subject: [PATCH] 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) --- .changeset/fn-7781-edit-workflow-steps.md | 7 ++ docs/dashboard-guide.md | 5 +- .../app/components/TaskDetailModal.tsx | 6 ++ .../dashboard/app/components/TaskForm.tsx | 76 +++++++++++----- ...ailModal.models-progress-workflow.test.tsx | 90 +++++++++++++++++++ .../components/__tests__/TaskForm.test.tsx | 51 +++++++++++ 6 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 .changeset/fn-7781-edit-workflow-steps.md diff --git a/.changeset/fn-7781-edit-workflow-steps.md b/.changeset/fn-7781-edit-workflow-steps.md new file mode 100644 index 0000000000..351109a3ba --- /dev/null +++ b/.changeset/fn-7781-edit-workflow-steps.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 903ad11669..f546f2068e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -323,6 +323,9 @@ Workflows define how a task moves through planning, execution, review, workflow 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. + +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. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 96f9b2c116..2cf3e3c968 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1055,6 +1055,9 @@ export function TaskDetailContent({ const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode)); const [editSelectedPresetId, setEditSelectedPresetId] = useState(""); const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState(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)} diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 339078283b..d2773e5d78 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -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({ )} + {mode === "edit" && onEnabledWorkflowStepsChange && (optionalStepsLoading || optionalSteps.length > 0) && ( +
+ {/* 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. */} + + {optionalStepsLoading ? ( + + {t("taskForm.optionalStepsLoading", "Loading optional steps…")} + + ) : ( + + )} +
+ )} + {(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx index b95b86cd80..73c9cb97d0 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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); diff --git a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx index be8d45a087..f34f951b55 100644 --- a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx @@ -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();