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. 22
      src/components/WorkflowCanvas.tsx
  2. 28
      src/components/edges/EditableEdge.tsx
  3. 1
      src/components/edges/SharedEdgeGradients.tsx
  4. 14
      src/store/workflowStore.ts

22
src/components/WorkflowCanvas.tsx

@ -70,6 +70,7 @@ import { stripBinaryData } from "@/lib/chat/contextBuilder";
import { PromptEditorModal } from "./modals/PromptEditorModal"; import { PromptEditorModal } from "./modals/PromptEditorModal";
import { PromptConstructorEditorModal } from "./modals/PromptConstructorEditorModal"; import { PromptConstructorEditorModal } from "./modals/PromptConstructorEditorModal";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs"; import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { wouldCreateCycle } from "@/store/utils/executionUtils";
import { parseVarTags } from "@/utils/parseVarTags"; import { parseVarTags } from "@/utils/parseVarTags";
import { AnnotationModal } from "./AnnotationModal"; import { AnnotationModal } from "./AnnotationModal";
import { browseRegistry } from "@/utils/browseRegistry"; import { browseRegistry } from "@/utils/browseRegistry";
@ -664,7 +665,12 @@ export function WorkflowCanvas() {
} }
} }
if (resolved.targetHandle) batchUsed.add(resolved.targetHandle); 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; return;
} }
@ -705,7 +711,12 @@ export function WorkflowCanvas() {
resolved = resolveSwitchHandle(resolved); resolved = resolveSwitchHandle(resolved);
if (resolved.targetHandle) batchUsed.add(resolved.targetHandle); if (resolved.targetHandle) batchUsed.add(resolved.targetHandle);
if (isValidConnection(resolved)) { 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 { } else {
@ -714,7 +725,12 @@ export function WorkflowCanvas() {
resolved = resolveRouterHandle(resolved); resolved = resolveRouterHandle(resolved);
resolved = resolveRouterSourceHandle(resolved); resolved = resolveRouterSourceHandle(resolved);
resolved = resolveSwitchHandle(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] [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 text: "#2563eb", // Blue for text connections
"3d": "#06b6d4", // Cyan for 3D connections "3d": "#06b6d4", // Cyan for 3D connections
easeCurve: "#f59e0b", // Amber for ease curve connections easeCurve: "#f59e0b", // Amber for ease curve connections
loop: "#d946ef", // Magenta for loop edges
}; };
export function EditableEdge({ export function EditableEdge({
@ -71,18 +72,23 @@ export function EditableEdge({
return (targetNode.data as NanoBananaNodeData).status === "loading"; 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(() => { const edgeColor = useMemo(() => {
if (edgeData?.isLoop) return EDGE_COLORS.loop;
if (hasPause) return EDGE_COLORS.pause; if (hasPause) return EDGE_COLORS.pause;
// Use source handle to determine color (or target if source is not available) // Use source handle to determine color (or target if source is not available)
// Strip numeric suffixes (e.g., "image-0" -> "image") for lookup // Strip numeric suffixes (e.g., "image-0" -> "image") for lookup
const handleType = sourceHandleId || targetHandleId || ""; const handleType = sourceHandleId || targetHandleId || "";
const normalizedType = handleType.replace(/-\d+$/, ""); const normalizedType = handleType.replace(/-\d+$/, "");
return EDGE_COLORS[normalizedType] || EDGE_COLORS.default; return EDGE_COLORS[normalizedType] || EDGE_COLORS.default;
}, [hasPause, sourceHandleId, targetHandleId]); }, [edgeData?.isLoop, hasPause, sourceHandleId, targetHandleId]);
// Reference shared gradient by color key + selection state // Reference shared gradient by color key + selection state
const gradientId = useMemo(() => { const gradientId = useMemo(() => {
if (edgeData?.isLoop) {
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return getSharedGradientId("loop", selectionKey);
}
if (hasPause) { if (hasPause) {
const selectionKey = isConnectedToSelection ? "active" : "dimmed"; const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return getSharedGradientId("pause", selectionKey); return getSharedGradientId("pause", selectionKey);
@ -93,7 +99,7 @@ export function EditableEdge({
const colorKey = normalizedType in EDGE_COLORS ? normalizedType : "default"; const colorKey = normalizedType in EDGE_COLORS ? normalizedType : "default";
const selectionKey = isConnectedToSelection ? "active" : "dimmed"; const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return getSharedGradientId(colorKey, selectionKey); return getSharedGradientId(colorKey, selectionKey);
}, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection]); }, [edgeData?.isLoop, hasPause, sourceHandleId, targetHandleId, isConnectedToSelection]);
// Calculate the path based on edge style // Calculate the path based on edge style
const [edgePath, labelX, labelY] = useMemo(() => { const [edgePath, labelX, labelY] = useMemo(() => {
@ -265,6 +271,22 @@ export function EditableEdge({
</g> </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 */} {/* Draggable handles on segments */}
{(selected || isDragging) && {(selected || isDragging) &&
handlePositions.map((handle, index) => ( handlePositions.map((handle, index) => (

1
src/components/edges/SharedEdgeGradients.tsx

@ -15,6 +15,7 @@ const EDGE_COLORS: Record<string, string> = {
text: "#2563eb", text: "#2563eb",
"3d": "#06b6d4", "3d": "#06b6d4",
easeCurve: "#f59e0b", easeCurve: "#f59e0b",
loop: "#d946ef",
}; };
const SELECTION_STATES = ["active", "dimmed"] as const; 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; addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => void;
removeEdge: (edgeId: string) => void; removeEdge: (edgeId: string) => void;
toggleEdgePause: (edgeId: string) => void; toggleEdgePause: (edgeId: string) => void;
setLoopCount: (edgeId: string, count: number) => void;
// Copy/Paste operations // Copy/Paste operations
copySelectedNodes: () => void; 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: () => { copySelectedNodes: () => {
const { nodes, edges } = get(); const { nodes, edges } = get();
const selectedNodes = nodes.filter((node) => node.selected); const selectedNodes = nodes.filter((node) => node.selected);

Loading…
Cancel
Save