+ {/* FNXC:WorkflowOptionalGroup 2026-06-21-14:38: mobile mirrors the desktop two-variant insert (node / optional group). */}
{templateGroups.stepEntries.map((s) => (
-
+
+
+
+
))}
)}
@@ -3155,22 +3202,48 @@ function InnerEditor({
{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}
+ {/*
+ FNXC:WorkflowOptionalGroup 2026-06-21-14:36:
+ Each built-in add-on surfaces TWO insert variants: the row inserts as a single node
+ (today's behavior), and a small secondary "as optional group" affordance wraps it in
+ an `optional-group` container (U5/R5). Both keep the established `wf-tpl-step-*` testid
+ convention (the wrap variant suffixes `-optional-group`).
+ */}
{templateGroups.stepEntries.map((s) => (
-
+
+
+
+
))}
diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
index b2cd3b22f2..b93babdb02 100644
--- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
+++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
-import { parseWorkflowIr, type WorkflowDefinition, type Settings } from "@fusion/core";
+import { parseWorkflowIr, WORKFLOW_STEP_TEMPLATES, type WorkflowDefinition, type Settings } from "@fusion/core";
import type { Agent } from "../../api";
import {
irToFlow,
@@ -386,12 +386,13 @@ describe("workflow-flow-mapping", () => {
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
const { edges } = edgeRenderableAssertion(builtinDef());
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
+ // FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place.
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
+ "browser-verification",
"execute",
"merge-attempt",
"planning",
"review",
- "workflow-step",
]);
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
@@ -2893,11 +2894,13 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
await screen.findByTestId("wf-palette-templates");
const filter = await screen.findByTestId("wf-template-filter");
- // All 8 step entries present pre-filter.
- expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(8);
+ // All 8 step entries present pre-filter. Match only the primary "insert as
+ // node" buttons, excluding the sibling "-optional-group" insert variant.
+ const primaryStep = /^wf-tpl-step-(?!.*-optional-group$).*/;
+ expect(screen.getAllByTestId(primaryStep).length).toBe(8);
// Filter to "Step 3" → only that step survives.
fireEvent.change(filter, { target: { value: "Step 3" } });
- await waitFor(() => expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(1));
+ await waitFor(() => expect(screen.getAllByTestId(primaryStep).length).toBe(1));
expect(screen.getByTestId("wf-tpl-step-s-3")).toBeInTheDocument();
// Fragment (name "Lint fragment") no longer matches.
expect(screen.queryByTestId("wf-tpl-fragment-WF-FRAG-A")).not.toBeInTheDocument();
@@ -2936,6 +2939,123 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
expect(screen.getByTestId("wf-tpl-step-qa-check")).toBeDisabled();
expect(screen.getByTestId("wf-tpl-plugin-acme-scan")).toBeDisabled();
});
+
+ // FNXC:WorkflowOptionalGroup 2026-06-21-14:50: All seven built-in add-ons must
+ // surface in the palette and insert two ways — as a single node (today's
+ // behavior, reusing stepTemplateToNode) and wrapped in an optional-group
+ // container (reusing insertFragment). These tests pin U5/R5.
+ it("surfaces all seven built-in add-ons in the palette", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
+ vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
+ templates: WORKFLOW_STEP_TEMPLATES,
+ });
+
+ render( {}} addToast={() => {}} />);
+ await screen.findByTestId("wf-palette-templates");
+
+ // Every add-on id is present as a primary "insert as node" button AND offers
+ // the "as optional group" sibling variant.
+ for (const tpl of WORKFLOW_STEP_TEMPLATES) {
+ expect(screen.getByTestId(`wf-tpl-step-${tpl.id}`)).toBeInTheDocument();
+ expect(
+ screen.getByTestId(`wf-tpl-step-${tpl.id}-optional-group`),
+ ).toBeInTheDocument();
+ }
+ expect(WORKFLOW_STEP_TEMPLATES).toHaveLength(7);
+ });
+
+ it("inserts an add-on as a single node carrying its template config", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
+ vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
+ vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
+ vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
+ templates: WORKFLOW_STEP_TEMPLATES,
+ });
+
+ render( {}} addToast={() => {}} />);
+ await screen.findByTestId("wf-palette-templates");
+ await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
+
+ const before = screen.queryAllByTestId("wf-node-prompt").length;
+ fireEvent.click(screen.getByTestId("wf-tpl-step-documentation-review"));
+ await waitFor(
+ () => expect(screen.queryAllByTestId("wf-node-prompt").length).toBe(before + 1),
+ { timeout: 3000 },
+ );
+
+ fireEvent.click(screen.getByText("Save").closest("button")!);
+ await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
+ const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
+ const ir = (updates as { ir: { nodes: { kind: string; config?: Record }[] } }).ir;
+ const docTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "documentation-review")!;
+ const inserted = ir.nodes.find((n) => n.config?.name === docTpl.name);
+ expect(inserted).toBeTruthy();
+ expect(inserted!.kind).toBe(docTpl.mode === "script" ? "script" : "prompt");
+ });
+
+ it("inserts an add-on as an optional-group whose template holds the projected node and defaultOn matches", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
+ vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
+ vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
+ vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
+ templates: WORKFLOW_STEP_TEMPLATES,
+ });
+
+ render( {}} addToast={() => {}} />);
+ await screen.findByTestId("wf-palette-templates");
+ await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
+
+ fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
+ // The wrapped add-on renders as a registered optional-group container.
+ await waitFor(
+ () => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(),
+ { timeout: 5000 },
+ );
+
+ fireEvent.click(screen.getByText("Save").closest("button")!);
+ await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
+ const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
+ const ir = (updates as { ir: { nodes: { kind: string; config?: Record }[] } }).ir;
+ const secTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "security-audit")!;
+ const group = ir.nodes.find((n) => n.kind === "optional-group");
+ expect(group).toBeTruthy();
+ expect(group!.config!.defaultOn).toBe(secTpl.defaultOn ?? false);
+ const template = group!.config!.template as { nodes: { kind: string; config?: Record }[] };
+ expect(template.nodes).toHaveLength(1);
+ expect(template.nodes[0].config?.name).toBe(secTpl.name);
+ });
+
+ it("remaps ids when the same add-on subgraph is inserted twice (no collision)", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
+ vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
+ vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
+ vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
+ templates: WORKFLOW_STEP_TEMPLATES,
+ });
+
+ render( {}} addToast={() => {}} />);
+ await screen.findByTestId("wf-palette-templates");
+ await screen.findByTestId("wf-node-gate", undefined, { timeout: 3000 });
+
+ fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
+ await waitFor(
+ () => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(1),
+ { timeout: 5000 },
+ );
+ fireEvent.click(screen.getByTestId("wf-tpl-step-security-audit-optional-group"));
+ await waitFor(
+ () => expect(screen.queryAllByTestId("wf-node-optional-group").length).toBe(2),
+ { timeout: 5000 },
+ );
+
+ fireEvent.click(screen.getByText("Save").closest("button")!);
+ await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
+ const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
+ const ir = (updates as { ir: { nodes: { id: string; kind: string }[] } }).ir;
+ const groupIds = ir.nodes.filter((n) => n.kind === "optional-group").map((n) => n.id);
+ expect(groupIds).toHaveLength(2);
+ expect(new Set(groupIds).size).toBe(2);
+ });
});
// ── U10: Design-with-AI editor affordances ──────────────────────────────────
diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts
index 03241c0644..864e9ddf79 100644
--- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts
+++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts
@@ -6,6 +6,7 @@ import {
irToFlow,
flowToIr,
insertFragment,
+ optionalGroupFragmentIr,
fragmentSeamConflicts,
copyIrWithFreshIds,
columnsOf,
@@ -1436,6 +1437,38 @@ describe("insertFragment", () => {
expect(template?.nodes).toHaveLength(2);
expect(template?.edges).toHaveLength(1);
});
+
+ // FNXC:WorkflowOptionalGroup 2026-06-21-14:55: optionalGroupFragmentIr wraps a
+ // projected add-on node in an optional-group; insertFragment must expand its
+ // template child and round-trip it via flowToIr, and two inserts must not collide.
+ it("wraps an add-on node in an optional-group fragment that round-trips with defaultOn", () => {
+ const fragmentIr = optionalGroupFragmentIr(
+ { kind: "prompt", config: { name: "Security Audit", prompt: "audit it" } },
+ { name: "Security Audit", defaultOn: true },
+ );
+
+ const existing = irToFlow(u8ChainDef());
+ const first = insertFragment(existing.nodes, existing.edges, fragmentIr, { x: 400, y: 200 });
+ const second = insertFragment(first.nodes, first.edges, fragmentIr, { x: 700, y: 200 });
+
+ // Two optional-group containers, each with its template child expanded.
+ const groups = second.nodes.filter((n) => n.data.kind === "optional-group");
+ expect(groups).toHaveLength(2);
+ for (const g of groups) {
+ expect(second.nodes.some((n) => n.parentId === g.id)).toBe(true);
+ }
+ // All ids disjoint across both inserts.
+ const allIds = second.nodes.map((n) => n.id);
+ expect(new Set(allIds).size).toBe(allIds.length);
+
+ // Round-trip: the group carries defaultOn + a single-node template.
+ const { ir: out } = flowToIr("wf", second.nodes, second.edges);
+ const og = out.nodes.find((n) => n.kind === "optional-group")!;
+ expect(og.config?.defaultOn).toBe(true);
+ const template = (og.config as { template?: { nodes: { config?: Record }[] } }).template;
+ expect(template?.nodes).toHaveLength(1);
+ expect(template?.nodes[0].config?.name).toBe("Security Audit");
+ });
});
describe("fragmentSeamConflicts", () => {
diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts
index 4605808ad7..f889a188f2 100644
--- a/packages/dashboard/app/components/workflow-flow-mapping.ts
+++ b/packages/dashboard/app/components/workflow-flow-mapping.ts
@@ -1250,6 +1250,52 @@ export function insertFragment(
};
}
+/*
+FNXC:WorkflowOptionalGroup 2026-06-21-14:30:
+"Insert as optional group" (U5/R5) wraps a single projected add-on node in an `optional-group`
+container so an author can drop e.g. "Security Audit (optional)" in one action. The wrapper is built
+as a v1-shaped fragment IR (start → optional-group → end) and handed to the EXISTING `insertFragment`
+path, which strips start/end, remaps the group id, and expands the group's `config.template` child as a
+`parentId` flow node — so no new insertion engine is needed and ids never collide across repeated inserts.
+KTD-5: the add-on catalog stays FLAT; projection to a node is done by the caller via `stepTemplateToNode`,
+and only the wrap-in-container step lives here.
+*/
+
+/** Wrap a single projected add-on node in an `optional-group` fragment IR ready
+ * for `insertFragment`. `defaultOn` seeds the group's per-task enable default
+ * (from the source template's `defaultOn`). The group's `name` labels it in the
+ * editor and the per-task toggle surfaces. The inner node uses a template-local
+ * id; `insertFragment` remaps the group id and namespaces the child, so this id
+ * need only be unique WITHIN the template. */
+export function optionalGroupFragmentIr(
+ addOnNode: { kind: WorkflowIrNodeKind; config?: Record },
+ opts: { name?: string; defaultOn?: boolean },
+): WorkflowIr {
+ const innerId = "addon";
+ const optionalGroupId = "optional-group";
+ const config: WorkflowOptionalGroupConfig & Record = {
+ defaultOn: opts.defaultOn ?? false,
+ template: {
+ nodes: [{ id: innerId, kind: addOnNode.kind, config: addOnNode.config }],
+ edges: [],
+ },
+ };
+ if (opts.name) config.name = opts.name;
+ return {
+ version: "v1",
+ name: opts.name ?? "optional-group",
+ nodes: [
+ { id: "start", kind: "start" },
+ { id: optionalGroupId, kind: "optional-group", config },
+ { id: "end", kind: "end" },
+ ],
+ edges: [
+ { from: "start", to: optionalGroupId, condition: "success" },
+ { from: optionalGroupId, to: "end", condition: "success" },
+ ],
+ };
+}
+
/** Remap a template group's internal node ids + edges to fresh ids. Returns a
* new template object; the original is untouched. Template-local ids are scoped
* to the template, so a fresh local id space suffices (and keeps config compact