import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { layerNodes } from "./workflow-auto-layout"; import { isColumnBandNode, isVisualOnlyWorkflowEdge, foreachChildFlowId, newNodeId, refreshTemplateContainerVisualBoundaries, shortConditionLabel, edgeClassName, WF_EDGE_INTERACTION_WIDTH, FOREACH_GROUP_WIDTH, FOREACH_GROUP_HEIGHT, FOREACH_CHILD_X, FOREACH_CHILD_Y, } from "./workflow-flow-mapping"; /* FNXC:WorkflowSimpleView 2026-07-10-12:00: The simplified workflow view renders the SAME node/edge state as the advanced canvas but with a derived, display-only vertical layout: users of the simple view never drag nodes, so readable structure must come from topology alone. Positions computed here are NOT written back into editor state — the persisted WorkflowDefinition.layout (advanced-canvas manual positions) stays untouched when someone merely views a workflow in simple mode, so toggling views never dirties a workflow. FNXC:WorkflowSimpleView 2026-07-10-12:00: Node insertion in the simple view happens on edges ("+" between two steps) because the simple canvas has no drag-to-connect and no free placement. The insert helper rewires source→new→target while PRESERVING the original edge's condition on the inbound edge (so outcome/failure routing authored upstream survives) and creating a default success edge out of the new node. The new node's REAL (advanced-canvas) position is placed at the source node's y so a v2 column-banded workflow keeps the node inside the source's column band and the save-time unplaced-node gate does not fire for simple-view authors who cannot drag nodes into bands. */ type LayoutNode = FlowNode; /** Card footprint of a simple-view step node (kept in sync with * WorkflowSimpleCanvas.css `.wf-simple-node` sizing). */ export const SIMPLE_NODE_WIDTH = 260; export const SIMPLE_NODE_HEIGHT = 84; /** Vertical gap between layers — roomy enough for the edge "+" affordance. */ export const SIMPLE_LAYER_GAP_Y = 72; /** Horizontal gap between siblings within one layer. */ export const SIMPLE_SIBLING_GAP_X = 48; function isSimpleLayoutable(node: LayoutNode): boolean { if (isColumnBandNode(node.id)) return false; if (node.parentId) return false; return true; } /** Estimated rendered width of the start/end terminal pills (they render as * compact pills, not full cards — see .wf-simple-terminal). Keeping the * layout estimate close to the real footprint keeps pills centered over * their neighbors so vertical edges run straight. */ export const SIMPLE_TERMINAL_WIDTH = 110; function nodeWidth(node: LayoutNode): number { const width = node.style?.width; if (typeof width === "number") return width; if (node.data.kind === "start" || node.data.kind === "end") return SIMPLE_TERMINAL_WIDTH; return SIMPLE_NODE_WIDTH; } function nodeHeight(node: LayoutNode): number { const height = node.style?.height; return typeof height === "number" ? height : SIMPLE_NODE_HEIGHT; } export type SimpleLayoutPositions = Map; /** * Compute display positions for a top-to-bottom simplified rendering of the * graph. Layering reuses the canvas auto-layout's longest-path algorithm; * within a layer siblings are centered horizontally around x=0, ordered by * their advanced-canvas x (then id) so the simple view's branch order stays * stable and familiar. Container groups (foreach/loop/optional-group) keep * their own width/height and their template children keep parent-relative * positions (untouched here — children are excluded like auto-layout does). */ export function simpleVerticalLayout(nodes: LayoutNode[], edges: FlowEdge[]): SimpleLayoutPositions { const layoutables = nodes.filter(isSimpleLayoutable); const ids = layoutables.map((n) => n.id); const byId = new Map(layoutables.map((n) => [n.id, n])); const layer = layerNodes(ids, edges); const layers = new Map(); for (const id of ids) { const l = layer.get(id) ?? 0; const arr = layers.get(l); if (arr) arr.push(id); else layers.set(l, [id]); } const positions: SimpleLayoutPositions = new Map(); const sortedLayerIndexes = [...layers.keys()].sort((a, b) => a - b); let y = 0; for (const layerIndex of sortedLayerIndexes) { const layerIds = [...(layers.get(layerIndex) ?? [])].sort((a, b) => { const na = byId.get(a)!; const nb = byId.get(b)!; if (na.position.x !== nb.position.x) return na.position.x - nb.position.x; return a < b ? -1 : a > b ? 1 : 0; }); const totalWidth = layerIds.reduce((sum, id) => sum + nodeWidth(byId.get(id)!), 0) + SIMPLE_SIBLING_GAP_X * Math.max(0, layerIds.length - 1); let x = -totalWidth / 2; let layerHeight = 0; for (const id of layerIds) { const node = byId.get(id)!; positions.set(id, { x, y }); x += nodeWidth(node) + SIMPLE_SIBLING_GAP_X; layerHeight = Math.max(layerHeight, nodeHeight(node)); } y += layerHeight + SIMPLE_LAYER_GAP_Y; } return positions; } /** True when the simple view should offer a "+" insert affordance on this * edge: a real (non-chrome) forward edge between existing nodes. Rework * edges are excluded — inserting "between" a rework loop-back has no clear * semantics and is an advanced-canvas job. */ export function edgeSupportsSimpleInsert(edge: FlowEdge): boolean { if (isVisualOnlyWorkflowEdge(edge)) return false; if ((edge.data?.kind as string | undefined) === "rework") return false; return true; } export interface SimpleInsertSpec { kind: WorkflowEditorNodeKind; label: string; presetConfig?: Record; /** Localized label for the seeded template child of container kinds. */ containerChildLabel?: string; } export interface SimpleInsertResult { nodes: LayoutNode[]; edges: FlowEdge[]; newNodeId: string; } const CONTAINER_KINDS: ReadonlySet = new Set([ "foreach", "loop", "optional-group", ]); function uniqueEdgeId(): string { // newNodeId() is already globally unique per session; prefix keeps edge ids // visually distinct from node ids in devtools/tests. return `e-${newNodeId()}`; } /** * Insert a new node "on" an existing edge: source→target becomes * source→new→target. Returns null when the edge cannot host an insert * (chrome/rework edge, missing endpoints, or a container kind dropped inside * another container). The caller owns read-only gating (built-ins). */ export function insertNodeOnEdge( nodes: LayoutNode[], edges: FlowEdge[], edgeId: string, spec: SimpleInsertSpec, ): SimpleInsertResult | null { const edge = edges.find((e) => e.id === edgeId); if (!edge || !edgeSupportsSimpleInsert(edge)) return null; const source = nodes.find((n) => n.id === edge.source); const target = nodes.find((n) => n.id === edge.target); if (!source || !target) return null; // Template-child edge (both endpoints inside the same container): insert a // sibling child. Containers cannot nest. const insideContainer = !!source.parentId && source.parentId === target.parentId; if (CONTAINER_KINDS.has(spec.kind) && insideContainer) return null; const id = newNodeId(); const baseConfig: Record = spec.kind === "gate" ? { gateMode: "gate" } : {}; const config = spec.presetConfig ? { ...baseConfig, ...spec.presetConfig } : baseConfig; // FNXC:WorkflowSimpleView 2026-07-10-12:00: // Real position: midpoint x (staggered so repeat inserts don't stack), but // the SOURCE node's y — same column band as the source in v2 workflows, so // the node is never born "unplaced" for authors who can't drag it. const position = insideContainer ? { x: (source.position.x + target.position.x) / 2 + 12, y: (source.position.y + target.position.y) / 2 + 12, } : { x: (source.position.x + target.position.x) / 2 + 24, y: source.position.y + 8, }; const newNodes: LayoutNode[] = []; if (CONTAINER_KINDS.has(spec.kind)) { // Mirror the palette addNode container seeding: group + one seeded child. const childId = foreachChildFlowId(id, newNodeId()); const childConfig = spec.kind === "foreach" ? { seam: "step-execute" } : { prompt: "" }; newNodes.push( { id, type: spec.kind, position, data: { kind: spec.kind, label: spec.label, config, templateEmpty: false }, style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT }, deletable: true, }, { id: childId, type: "prompt", position: { x: FOREACH_CHILD_X, y: FOREACH_CHILD_Y }, parentId: id, extent: "parent", data: { kind: "prompt", label: spec.containerChildLabel ?? "Step", config: childConfig, }, deletable: true, }, ); } else { newNodes.push({ id, type: spec.kind, position, ...(insideContainer ? { parentId: source.parentId, extent: "parent" as const } : {}), data: { kind: spec.kind, label: spec.label, config }, deletable: true, }); } const inboundCondition = (edge.data?.condition as string | undefined) ?? "success"; const inbound: FlowEdge = { id: uniqueEdgeId(), source: edge.source, target: id, label: shortConditionLabel(inboundCondition), data: { condition: inboundCondition, kind: undefined }, className: edgeClassName(inboundCondition, false), interactionWidth: WF_EDGE_INTERACTION_WIDTH, }; const outbound: FlowEdge = { id: uniqueEdgeId(), source: id, target: edge.target, label: shortConditionLabel("success"), data: { condition: "success", kind: undefined }, className: edgeClassName("success", false), interactionWidth: WF_EDGE_INTERACTION_WIDTH, }; const nextNodes = [...nodes, ...newNodes]; const nextEdges = [...edges.filter((e) => e.id !== edgeId), inbound, outbound]; const refreshed = refreshTemplateContainerVisualBoundaries(nextNodes, nextEdges); return { nodes: refreshed.nodes, edges: refreshed.edges, newNodeId: id }; } /* FNXC:WorkflowSimpleView 2026-07-12-10:30: PR #2006 review: fragment and "insert as optional group" picks from an edge-targeted add-step dialog previously fell through to the advanced fixed-position insertFragment path, leaving the "+" edge untouched and the inserted subgraph disconnected. This splice helper wires an ALREADY-inserted subgraph into the targeted edge: source→(subgraph entries) preserving the original routing condition, (subgraph exits)→target as success, original edge removed, and the subgraph translated next to the source node's y so v2 column banding stays valid for simple-view authors who cannot drag. */ /** * Wire an inserted subgraph (from insertFragment) into an existing edge. * Entries/exits derive from in/out degree over the subgraph's internal * non-rework edges (mirroring template boundary semantics). Returns null when * the edge is gone/ineligible or the subgraph has no top-level nodes — the * caller keeps the plain fixed-position insert in that case. */ export function spliceInsertedSubgraphOnEdge( nodes: LayoutNode[], edges: FlowEdge[], edgeId: string, insertedNodeIds: readonly string[], ): { nodes: LayoutNode[]; edges: FlowEdge[] } | null { const edge = edges.find((e) => e.id === edgeId); if (!edge || !edgeSupportsSimpleInsert(edge)) return null; const source = nodes.find((n) => n.id === edge.source); const target = nodes.find((n) => n.id === edge.target); if (!source || !target) return null; // FNXC:WorkflowSimpleView 2026-07-12-14:30: PR #2006 review — subgraphs // insert as TOP-LEVEL nodes, so splicing one into a container-internal // (template child) edge would wire cross-boundary edges into the container. // Refuse; the caller falls back to the fixed-position insert, and the // add-step dialog hides fragments for container-edge targets anyway. if (source.parentId || target.parentId) return null; const insertedSet = new Set(insertedNodeIds); const insertedTop = nodes.filter( (n) => insertedSet.has(n.id) && !n.parentId && !isColumnBandNode(n.id), ); if (insertedTop.length === 0) return null; const topIds = new Set(insertedTop.map((n) => n.id)); const indegree = new Map(); const outdegree = new Map(); for (const id of topIds) { indegree.set(id, 0); outdegree.set(id, 0); } for (const e of edges) { if (isVisualOnlyWorkflowEdge(e)) continue; if ((e.data?.kind as string | undefined) === "rework") continue; if (!topIds.has(e.source) || !topIds.has(e.target)) continue; outdegree.set(e.source, (outdegree.get(e.source) ?? 0) + 1); indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1); } const entries = insertedTop.filter((n) => (indegree.get(n.id) ?? 0) === 0); const exits = insertedTop.filter((n) => (outdegree.get(n.id) ?? 0) === 0); const entryNodes = entries.length > 0 ? entries : insertedTop; const exitNodes = exits.length > 0 ? exits : insertedTop; // Translate the subgraph beside the wiring point: same y band as the source // (column validity), x around the source/target midpoint. Relative layout // inside the subgraph is preserved. const minX = Math.min(...insertedTop.map((n) => n.position.x)); const minY = Math.min(...insertedTop.map((n) => n.position.y)); const deltaX = (source.position.x + target.position.x) / 2 + 24 - minX; const deltaY = source.position.y + 8 - minY; const nextNodes = nodes.map((n) => topIds.has(n.id) ? { ...n, position: { x: n.position.x + deltaX, y: n.position.y + deltaY } } : n, ); const inboundCondition = (edge.data?.condition as string | undefined) ?? "success"; const newEdges: FlowEdge[] = [ ...entryNodes.map((entry) => ({ id: `e-${newNodeId()}`, source: edge.source, target: entry.id, label: shortConditionLabel(inboundCondition), data: { condition: inboundCondition, kind: undefined }, className: edgeClassName(inboundCondition, false), interactionWidth: WF_EDGE_INTERACTION_WIDTH, })), ...exitNodes.map((exit) => ({ id: `e-${newNodeId()}`, source: exit.id, target: edge.target, label: shortConditionLabel("success"), data: { condition: "success", kind: undefined }, className: edgeClassName("success", false), interactionWidth: WF_EDGE_INTERACTION_WIDTH, })), ]; const nextEdges = [...edges.filter((e) => e.id !== edgeId), ...newEdges]; return refreshTemplateContainerVisualBoundaries(nextNodes, nextEdges); } /** * The simple view's "+ Add step" (no specific edge): insert before the `end` * node when a single wiring point is unambiguous, otherwise signal the caller * to fall back to a free-floating addNode. Returns the edge id to insert on, * or null when no suitable edge exists. */ export function findAppendEdgeId(nodes: LayoutNode[], edges: FlowEdge[]): string | null { const intoEnd = edges.filter( (e) => e.target === "end" && edgeSupportsSimpleInsert(e) && nodes.some((n) => n.id === e.source), ); if (intoEnd.length === 1) return intoEnd[0].id; return null; }