From be7c23693c08edcd90c31e1e661da502f91f0d6c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 18:29:58 -0700 Subject: [PATCH] feat(FN-6880): author optional-group container in the node editor (U4) Render and author optional-group as a third group-container kind beside foreach/loop: register the node type (OptionalGroupNode) so it renders with a header + defaultOn badge and parentId template children, treat it as a group everywhere in workflow-flow-mapping (irToFlow children, flowToIr template reassembly, cascade-delete, condition-editable), add a palette entry, and an inspector defaultOn toggle. Includes a node-help entry and round-trip / toggle / cascade-delete tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/WorkflowNodeEditor.tsx | 46 +++++-- .../__tests__/WorkflowNodeEditor.test.tsx | 119 ++++++++++++++++++ .../__tests__/workflow-flow-mapping.test.ts | 98 +++++++++++++++ .../components/nodes/WorkflowNodeTypes.tsx | 41 +++++- .../nodes/__tests__/node-help.test.ts | 1 + .../app/components/nodes/node-help.ts | 12 ++ .../app/components/workflow-flow-mapping.ts | 52 ++++++-- 7 files changed, 350 insertions(+), 19 deletions(-) diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index dc681b0401..9a82b6ffa4 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -16,7 +16,7 @@ import { } from "@xyflow/react"; 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, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; +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 { getErrorMessage } from "@fusion/core"; import { @@ -236,6 +236,8 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof // Step-inversion (KTD-3/4/12/15). { kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } }, { kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } }, + // FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group container holds a template subgraph run once when the task enables it (per-task `enabledWorkflowSteps`, seeded from `defaultOn`) and skipped otherwise. + { kind: "optional-group", label: "Optional group", icon: ToggleRight, presetConfig: { defaultOn: false } }, { kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } }, { kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } }, { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, @@ -287,6 +289,7 @@ const USER_NODE_KINDS: ReadonlySet = new Set [ ...ns, @@ -1941,11 +1947,14 @@ function InnerEditor({ let errorBadge: string | undefined; if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column"); if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message; - const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop"; + const isTemplateGroup = + n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group"; const emptyHint = n.data.kind === "loop" ? t("workflowNodes.loopEmptyHint", "Drag loop steps here") - : t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here"); + : n.data.kind === "optional-group" + ? t("workflowNodes.optionalGroupEmptyHint", "Drag optional steps here") + : t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here"); const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined; if ( errorBadge === n.data.errorBadge && @@ -4108,6 +4117,27 @@ function InnerEditor({ })() ) : null} + {/* FNXC:WorkflowOptionalGroup 2026-06-21-11:30: The optional-group inspector exposes the workflow-author `defaultOn` default (whether new tasks enable the group). The group name reuses the shared Name field above; the body is authored by dropping nodes inside, identical to foreach/loop. */} + {selectedNode.data.kind === "optional-group" ? ( + <> + +

+ {t( + "workflowNodes.optionalGroupNote", + "Runs the steps inside this group once when the task enables it (seeded from this default), and skips them when disabled. Drop the optional steps into the region.", + )} +

+ + ) : null} + {selectedNode.data.kind === "step-review" ? ( <>