From b83542ac56dd17e42069e03a6d90bad24c9f3481 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 2 Apr 2026 23:01:59 +1300 Subject: [PATCH] feat(48-02): auto-detect loop edges with magenta visual styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import wouldCreateCycle from executionUtils in WorkflowCanvas - Add cycle detection in handleConnect for batch and single connections - Mark backward connections as loop edges (isLoop: true, loopCount: 3) - Add setLoopCount store action with 1-100 clamping - Add loop color (#d946ef magenta) to EDGE_COLORS in both files - Loop edges render in magenta before pause edges - Add loop indicator overlay (↻ N×) at edge midpoint using foreignObject - Loop gradient defined in SharedEdgeGradients --- src/components/WorkflowCanvas.tsx | 22 ++++++++++++--- src/components/edges/EditableEdge.tsx | 28 +++++++++++++++++--- src/components/edges/SharedEdgeGradients.tsx | 1 + src/store/workflowStore.ts | 14 ++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 51904ad1..343de008 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -70,6 +70,7 @@ import { stripBinaryData } from "@/lib/chat/contextBuilder"; import { PromptEditorModal } from "./modals/PromptEditorModal"; import { PromptConstructorEditorModal } from "./modals/PromptConstructorEditorModal"; import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs"; +import { wouldCreateCycle } from "@/store/utils/executionUtils"; import { parseVarTags } from "@/utils/parseVarTags"; import { AnnotationModal } from "./AnnotationModal"; import { browseRegistry } from "@/utils/browseRegistry"; @@ -664,7 +665,12 @@ export function WorkflowCanvas() { } } if (resolved.targetHandle) batchUsed.add(resolved.targetHandle); - onConnect(resolved); + // Check for cycle and mark as loop edge if detected + if (wouldCreateCycle(resolved.source!, resolved.target!, edges)) { + onConnect(resolved, { isLoop: true, loopCount: 3 }); + } else { + onConnect(resolved); + } return; } @@ -705,7 +711,12 @@ export function WorkflowCanvas() { resolved = resolveSwitchHandle(resolved); if (resolved.targetHandle) batchUsed.add(resolved.targetHandle); if (isValidConnection(resolved)) { - onConnect(resolved); + // Check for cycle and mark as loop edge if detected + if (wouldCreateCycle(resolved.source!, resolved.target!, edges)) { + onConnect(resolved, { isLoop: true, loopCount: 3 }); + } else { + onConnect(resolved); + } } }); } else { @@ -714,7 +725,12 @@ export function WorkflowCanvas() { resolved = resolveRouterHandle(resolved); resolved = resolveRouterSourceHandle(resolved); resolved = resolveSwitchHandle(resolved); - onConnect(resolved); + // Check for cycle and mark as loop edge if detected + if (wouldCreateCycle(resolved.source!, resolved.target!, edges)) { + onConnect(resolved, { isLoop: true, loopCount: 3 }); + } else { + onConnect(resolved); + } } }, [onConnect, nodes, edges] diff --git a/src/components/edges/EditableEdge.tsx b/src/components/edges/EditableEdge.tsx index eee67525..1b4a13ad 100644 --- a/src/components/edges/EditableEdge.tsx +++ b/src/components/edges/EditableEdge.tsx @@ -29,6 +29,7 @@ const EDGE_COLORS: Record = { text: "#2563eb", // Blue for text connections "3d": "#06b6d4", // Cyan for 3D connections easeCurve: "#f59e0b", // Amber for ease curve connections + loop: "#d946ef", // Magenta for loop edges }; export function EditableEdge({ @@ -71,18 +72,23 @@ export function EditableEdge({ return (targetNode.data as NanoBananaNodeData).status === "loading"; }); - // Determine edge color based on handle type (orange if paused) + // Determine edge color based on handle type (magenta for loop edges, orange if paused) const edgeColor = useMemo(() => { + if (edgeData?.isLoop) return EDGE_COLORS.loop; if (hasPause) return EDGE_COLORS.pause; // Use source handle to determine color (or target if source is not available) // Strip numeric suffixes (e.g., "image-0" -> "image") for lookup const handleType = sourceHandleId || targetHandleId || ""; const normalizedType = handleType.replace(/-\d+$/, ""); return EDGE_COLORS[normalizedType] || EDGE_COLORS.default; - }, [hasPause, sourceHandleId, targetHandleId]); + }, [edgeData?.isLoop, hasPause, sourceHandleId, targetHandleId]); // Reference shared gradient by color key + selection state const gradientId = useMemo(() => { + if (edgeData?.isLoop) { + const selectionKey = isConnectedToSelection ? "active" : "dimmed"; + return getSharedGradientId("loop", selectionKey); + } if (hasPause) { const selectionKey = isConnectedToSelection ? "active" : "dimmed"; return getSharedGradientId("pause", selectionKey); @@ -93,7 +99,7 @@ export function EditableEdge({ const colorKey = normalizedType in EDGE_COLORS ? normalizedType : "default"; const selectionKey = isConnectedToSelection ? "active" : "dimmed"; return getSharedGradientId(colorKey, selectionKey); - }, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection]); + }, [edgeData?.isLoop, hasPause, sourceHandleId, targetHandleId, isConnectedToSelection]); // Calculate the path based on edge style const [edgePath, labelX, labelY] = useMemo(() => { @@ -265,6 +271,22 @@ export function EditableEdge({ )} + {/* Loop indicator at edge midpoint */} + {edgeData?.isLoop && ( + +
+ + {edgeData.loopCount || 3}× +
+
+ )} + {/* Draggable handles on segments */} {(selected || isDragging) && handlePositions.map((handle, index) => ( diff --git a/src/components/edges/SharedEdgeGradients.tsx b/src/components/edges/SharedEdgeGradients.tsx index d41f09dd..cfb32751 100644 --- a/src/components/edges/SharedEdgeGradients.tsx +++ b/src/components/edges/SharedEdgeGradients.tsx @@ -15,6 +15,7 @@ const EDGE_COLORS: Record = { text: "#2563eb", "3d": "#06b6d4", easeCurve: "#f59e0b", + loop: "#d946ef", }; const SELECTION_STATES = ["active", "dimmed"] as const; diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 2029e900..f0f11528 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -235,6 +235,7 @@ interface WorkflowStore { addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record) => void; removeEdge: (edgeId: string) => void; toggleEdgePause: (edgeId: string) => void; + setLoopCount: (edgeId: string, count: number) => void; // Copy/Paste operations copySelectedNodes: () => void; @@ -893,6 +894,19 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ })); }, + setLoopCount: (edgeId: string, count: number) => { + const clamped = Math.max(1, Math.min(100, count)); + pushUndoCheckpoint(get, set); + set((state) => ({ + edges: state.edges.map((edge) => + edge.id === edgeId + ? { ...edge, data: { ...edge.data, loopCount: clamped } } + : edge + ), + hasUnsavedChanges: true, + })); + }, + copySelectedNodes: () => { const { nodes, edges } = get(); const selectedNodes = nodes.filter((node) => node.selected);