Files
fusion/packages/dashboard/app/components/workflow-simple-layout.ts
gsxdsm 4679c4960f Address PR review feedback (#2006)
- fix: fragments are hidden in the add-step dialog when the target edge is
  inside a foreach/loop/optional-group (they expand to top-level subgraphs
  and cannot splice into a template-child edge), and
  spliceInsertedSubgraphOnEdge now refuses container-internal edges as a
  second line of defense (Greptile P1 x2).
- test: add-step modal container-target hiding, multi-entry/exit splice
  fan-out, internal-cycle entries fallback, ambiguous merge-inbound
  lifecycle fallback, and edge-targeted "as optional group" wiring
  (CodeRabbit nitpicks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:06:22 -07:00

386 lines
15 KiB
TypeScript

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<WorkflowFlowNodeData>;
/** 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<string, { x: number; y: number }>;
/**
* 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<number, string[]>();
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<string, unknown>;
/** 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<WorkflowEditorNodeKind> = 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<string, unknown> = 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<string, number>();
const outdegree = new Map<string, number>();
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;
}