Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 16 additions & 47 deletions backend/app/nodes/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,16 +290,13 @@ def derive_sub_workflow_ports(
"""Derive input/output port definitions from SubWorkflowInput/Output nodes.

Returns (input_ports, output_ports) where each port is
{"name": str, "type": "event"|"value"} derived from connected edge types.
{"name": str, "type": "event"|"value"} derived from connected handle names.
Only connected nodes are included.
"""
if edges_data is None:
edges_data = graph_data.get("edges", [])
nodes_data = graph_data.get("nodes", [])

# Map node_id -> node data
node_map = {n["id"]: n for n in nodes_data}

input_ports = []
output_ports = []

Expand All @@ -312,71 +309,43 @@ def derive_sub_workflow_ports(
continue

if node_type == "sub_workflow_input":
# Find edges where this node is the source
port_type = _infer_port_type_from_edges(node_id, "source", edges_data, node_map)
port_type = _detect_port_type(node_id, "source", edges_data)
if port_type:
input_ports.append({"name": name, "type": port_type})

elif node_type == "sub_workflow_output":
# Find edges where this node is the target
port_type = _infer_port_type_from_edges(node_id, "target", edges_data, node_map)
port_type = _detect_port_type(node_id, "target", edges_data)
if port_type:
output_ports.append({"name": name, "type": port_type})

return input_ports, output_ports


def _infer_port_type_from_edges(
_HANDLE_TO_PORT_TYPE: dict[str, str] = {
"event": "event",
"value": "value",
}


def _detect_port_type(
node_id: str,
role: str, # "source" or "target"
edges_data: list[dict],
node_map: dict[str, dict],
) -> str | None:
"""Infer port type (event/value) from what the Input/Output node is connected to.
"""Detect port type from the handle names used on a SubWorkflowInput/Output node.

Returns None if not connected, raises PortTypeMismatchError if mixed types.
"""
from app.nodes import NODES_MAP

handle_key = f"{role}Handle"
inferred: set[str] = set()

for edge in edges_data:
if edge.get(role) != node_id:
continue

# The other side of the connection
other_role = "target" if role == "source" else "source"
other_id = edge.get(other_role)
other_handle = edge.get(f"{other_role}Handle", "")
other_node_data = node_map.get(other_id)

if not other_node_data:
continue

other_type = other_node_data.get("type", "")

# Look up port definition from the other node's class
# Handle sub_workflow_{id} types
if _SUB_WORKFLOW_TYPE_RE.match(other_type):
# Can't determine port type from dynamic sub-workflow nodes statically
# Default to value
inferred.add("value")
continue

cls = NODES_MAP.get(other_type)
if not cls:
continue

# Find the port definition matching the handle
ports = cls.input_ports if other_role == "target" else cls.output_ports
for port_def in ports:
if port_def.name == other_handle:
# Map port type to event or value
if port_def.type == "event":
inferred.add("event")
else:
inferred.add("value")
break
handle = edge.get(handle_key, "")
port_type = _HANDLE_TO_PORT_TYPE.get(handle)
if port_type:
inferred.add(port_type)

if not inferred:
return None
Expand Down
7 changes: 5 additions & 2 deletions backend/app/nodes/sub_workflow_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ class SubWorkflowInput(AbstractNode):
label = "Input"
description = "Entry point for sub-workflow — exposes an input port"
input_ports = (PortDef(name="name", type="text", label="Name", control="text"),)
output_ports = (PortDef(name="output", type="value", label="Output", color="blue"),)
output_ports = (
PortDef(name="event", type="event", label="Event", color="yellow"),
PortDef(name="value", type="value", label="Value", color="blue"),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -23,5 +26,5 @@ def evaluate(self, source_handle: str | None = None):

async def execute(self) -> None:
"""Propagate event to connected nodes."""
for node in self.output_connections.get("output", []):
for node in self.output_connections.get("event", []):
await node.execute()
5 changes: 3 additions & 2 deletions backend/app/nodes/sub_workflow_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ class SubWorkflowOutput(AbstractNode):
description = "Exit point for sub-workflow — collects an output value"
input_ports = (
PortDef(name="name", type="text", label="Name", control="text"),
PortDef(name="input", type="value", label="Input", color="blue"),
PortDef(name="event", type="event", label="Event", color="yellow"),
PortDef(name="value", type="value", label="Value", color="blue"),
)
output_ports = ()

Expand All @@ -18,7 +19,7 @@ def __init__(self, *args, **kwargs):
self._event_fired = False

def evaluate(self, source_handle: str | None = None):
return self.get_input("input")
return self.get_input("value")

async def execute(self) -> None:
self._event_fired = True
188 changes: 188 additions & 0 deletions frontend/src/components/workflow/use-workflow-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import type { Edge, Node } from "@xyflow/react";
import { useCallback, useEffect, useRef } from "react";

interface ClipboardData {
nodes: Node[];
edges: Edge[];
}

interface UndoSnapshot {
nodes: Node[];
edges: Edge[];
}

const PASTE_CASCADE_OFFSET = 50;
const MAX_UNDO_STACK = 50;

let nodeIdCounter = 0;
function nextPasteId() {
return `paste_${Date.now()}_${nodeIdCounter++}`;
}

// Module-level clipboard so it persists across sub-workflow navigation
let sharedClipboard: ClipboardData | null = null;
let sharedPastePosition: { x: number; y: number } | null = null;
let sharedPasteCount = 0;

/**
* Provides Ctrl+X/C/V (cut/copy/paste) and Ctrl+Z (undo) for the workflow editor.
*/
export function useWorkflowClipboard({
nodesRef,
edgesRef,
setNodes,
setEdges,
readyRef,
changeCountRef,
containerRef,
}: {
nodesRef: React.RefObject<Node[]>;
edgesRef: React.RefObject<Edge[]>;
setNodes: React.Dispatch<React.SetStateAction<Node[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
readyRef: React.RefObject<boolean>;
changeCountRef: React.MutableRefObject<number>;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const undoStackRef = useRef<UndoSnapshot[]>([]);

const setPastePosition = useCallback((pos: { x: number; y: number }) => {
sharedPastePosition = pos;
sharedPasteCount = 0;
}, []);

const takeSnapshot = useCallback((): UndoSnapshot => {
return {
nodes: nodesRef.current.map((n) => ({ ...n, data: { ...n.data } })),
edges: edgesRef.current.map((e) => ({ ...e })),
};
}, [nodesRef, edgesRef]);

const pushUndo = useCallback(() => {
const snapshot = takeSnapshot();
undoStackRef.current.push(snapshot);
if (undoStackRef.current.length > MAX_UNDO_STACK) {
undoStackRef.current.shift();
}
}, [takeSnapshot]);

const copySelected = useCallback(() => {
const nodes = nodesRef.current;
const edges = edgesRef.current;
const selectedNodes = nodes.filter((n) => n.selected);
if (selectedNodes.length === 0) return;

const selectedIds = new Set(selectedNodes.map((n) => n.id));
const internalEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target));

sharedClipboard = { nodes: selectedNodes, edges: internalEdges };
sharedPastePosition = null;
sharedPasteCount = 0;
}, [nodesRef, edgesRef]);

const cutSelected = useCallback(() => {
const selectedNodes = nodesRef.current.filter((n) => n.selected);
if (selectedNodes.length === 0) return;

pushUndo();
copySelected();

const cutIds = new Set(selectedNodes.map((n) => n.id));
setNodes((nds) => nds.filter((n) => !cutIds.has(n.id)));
setEdges((eds) => eds.filter((e) => !cutIds.has(e.source) && !cutIds.has(e.target)));
if (readyRef.current) changeCountRef.current++;
}, [nodesRef, pushUndo, copySelected, setNodes, setEdges, readyRef, changeCountRef]);

const paste = useCallback(() => {
if (!sharedClipboard || sharedClipboard.nodes.length === 0) return;

pushUndo();
const cascadeOffset = PASTE_CASCADE_OFFSET * sharedPasteCount;
sharedPasteCount++;

// Compute the center of the copied nodes
const copied = sharedClipboard.nodes;
const centerX = copied.reduce((sum, n) => sum + n.position.x, 0) / copied.length;
const centerY = copied.reduce((sum, n) => sum + n.position.y, 0) / copied.length;

// If a paste position was set (pane click), place relative to it; otherwise offset from original
const target = sharedPastePosition;
const dx = target ? target.x - centerX + cascadeOffset : cascadeOffset + PASTE_CASCADE_OFFSET;
const dy = target ? target.y - centerY + cascadeOffset : cascadeOffset + PASTE_CASCADE_OFFSET;

const idMap = new Map<string, string>();
const newNodes: Node[] = copied.map((n) => {
const newId = nextPasteId();
idMap.set(n.id, newId);
return {
...n,
id: newId,
position: { x: n.position.x + dx, y: n.position.y + dy },
selected: true,
data: { ...n.data },
};
});

const newEdges: Edge[] = sharedClipboard.edges.map((e) => ({
...e,
id: nextPasteId(),
source: idMap.get(e.source) ?? e.source,
target: idMap.get(e.target) ?? e.target,
}));

// Deselect existing nodes, add new ones selected
setNodes((nds) => [...nds.map((n) => (n.selected ? { ...n, selected: false } : n)), ...newNodes]);
setEdges((eds) => [...eds, ...newEdges]);
if (readyRef.current) changeCountRef.current++;
}, [pushUndo, setNodes, setEdges, readyRef, changeCountRef]);

const undo = useCallback(() => {
const snapshot = undoStackRef.current.pop();
if (!snapshot) return;

setNodes(snapshot.nodes);
setEdges(snapshot.edges);
if (readyRef.current) changeCountRef.current++;
}, [setNodes, setEdges, readyRef, changeCountRef]);

// Keyboard handler — listen on document, but only act when focus is inside the editor
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const container = containerRef.current;
if (!container) return;
if (!container.contains(e.target as HTMLElement)) return;

// Ignore if typing in an input/textarea/select
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if ((e.target as HTMLElement)?.isContentEditable) return;

const mod = e.ctrlKey || e.metaKey;
if (!mod) return;

switch (e.key.toLowerCase()) {
case "c":
e.preventDefault();
copySelected();
break;
case "x":
e.preventDefault();
cutSelected();
break;
case "v":
e.preventDefault();
paste();
break;
case "z":
e.preventDefault();
undo();
break;
}
}

document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [containerRef, copySelected, cutSelected, paste, undo]);

return { pushUndo, setPastePosition };
}
Loading