Browse Source

feat(48-02): auto-detect loop edges with magenta visual styling

- 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
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
b83542ac56
  1. 16
      src/components/WorkflowCanvas.tsx
  2. 28
      src/components/edges/EditableEdge.tsx
  3. 1
      src/components/edges/SharedEdgeGradients.tsx
  4. 14
      src/store/workflowStore.ts

16
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);
// 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,8 +711,13 @@ export function WorkflowCanvas() {
resolved = resolveSwitchHandle(resolved);
if (resolved.targetHandle) batchUsed.add(resolved.targetHandle);
if (isValidConnection(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 {
// Single connection
@ -714,8 +725,13 @@ export function WorkflowCanvas() {
resolved = resolveRouterHandle(resolved);
resolved = resolveRouterSourceHandle(resolved);
resolved = resolveSwitchHandle(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]
);

28
src/components/edges/EditableEdge.tsx

@ -29,6 +29,7 @@ const EDGE_COLORS: Record<string, string> = {
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({
</g>
)}
{/* Loop indicator at edge midpoint */}
{edgeData?.isLoop && (
<foreignObject
x={labelX - 28}
y={labelY - 12}
width={56}
height={24}
className="pointer-events-none"
>
<div className="flex items-center justify-center gap-1 px-2 py-0.5 bg-neutral-800/90 border border-fuchsia-500/60 rounded-full text-[10px] font-medium">
<span className="text-fuchsia-300"></span>
<span className="text-fuchsia-100">{edgeData.loopCount || 3}×</span>
</div>
</foreignObject>
)}
{/* Draggable handles on segments */}
{(selected || isDragging) &&
handlePositions.map((handle, index) => (

1
src/components/edges/SharedEdgeGradients.tsx

@ -15,6 +15,7 @@ const EDGE_COLORS: Record<string, string> = {
text: "#2563eb",
"3d": "#06b6d4",
easeCurve: "#f59e0b",
loop: "#d946ef",
};
const SELECTION_STATES = ["active", "dimmed"] as const;

14
src/store/workflowStore.ts

@ -235,6 +235,7 @@ interface WorkflowStore {
addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => 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<WorkflowStore> = (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);

Loading…
Cancel
Save