diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 9a82b6ffa4..726b6e8769 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -17,7 +17,7 @@ import { import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; -import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep, WorkflowIrNodeKind } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -61,6 +61,7 @@ import { emptyWorkflowLayout, copyIrWithFreshIds, insertFragment, + optionalGroupFragmentIr, fragmentSeamConflicts, columnsOf, fieldsOf, @@ -1428,6 +1429,34 @@ function InnerEditor({ [isBuiltin, addNode], ); + /* + FNXC:WorkflowOptionalGroup 2026-06-21-14:32: + "Insert as optional group" (U5/R5): drop an add-on already wrapped in an `optional-group` container in + one action, seeding the group's `defaultOn` from the template's `defaultOn`. Reuses `stepTemplateToNode` + (KTD-5 — the catalog stays flat) to project the add-on to a prompt/script node, then `optionalGroupFragmentIr` + to wrap it and the EXISTING `insertFragment` path to remap ids + expand the group's template child — so two + inserts of the same add-on never collide. The group name carries the template name so the per-task toggle + surfaces label it. + */ + const handleInsertStepTemplateAsOptionalGroup = useCallback( + (tpl: WorkflowStepTemplate) => { + if (isBuiltin) return; + const { kind, config } = stepTemplateToNode(tpl); + const fragmentIr = optionalGroupFragmentIr( + { kind: kind as WorkflowIrNodeKind, config }, + { name: tpl.name, defaultOn: tpl.defaultOn ?? false }, + ); + const result = insertFragment(nodes, edges, fragmentIr, { + x: 240, + y: 200 + (nodes.length % 4) * 40, + }); + setNodes(result.nodes); + setEdges(result.edges); + setSelectedNodeId(result.insertedNodeIds[0] ?? null); + }, + [isBuiltin, nodes, edges, setNodes, setEdges], + ); + // U9/R8: insert a fragment definition's body into the active graph. Pre-validates // seam duplication via fragmentSeamConflicts; on conflict, surfaces a persistent // inline error inside the Templates section and does NOT insert. Otherwise @@ -2751,19 +2780,37 @@ function InnerEditor({ {templateGroups.stepEntries.length > 0 && (

{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}

+ {/* 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