Browse Source

QOL updates, connection pauses, UX issue fixes.

handoff-20260429-1057
Shrimbly 7 months ago
parent
commit
5584ca024e
  1. 12
      src/app/globals.css
  2. 2
      src/app/layout.tsx
  3. 242
      src/components/AnnotationModal.tsx
  4. 101
      src/components/EdgeToolbar.tsx
  5. 194
      src/components/FloatingActionBar.tsx
  6. 82
      src/components/Toast.tsx
  7. 147
      src/components/WorkflowCanvas.tsx
  8. 28
      src/components/edges/EditableEdge.tsx
  9. 2
      src/components/nodes/AnnotationNode.tsx
  10. 2
      src/components/nodes/ImageInputNode.tsx
  11. 2
      src/components/nodes/NanoBananaNode.tsx
  12. 2
      src/components/nodes/OutputNode.tsx
  13. 2
      src/components/nodes/PromptNode.tsx
  14. 149
      src/store/workflowStore.ts
  15. 7
      src/types/index.ts

12
src/app/globals.css

@ -29,6 +29,18 @@ body {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
/* Larger invisible hit area for easier clicking, especially when zoomed out */
.react-flow__handle::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
border-radius: 50%;
}
.react-flow__handle-left {
left: -5px;
}

2
src/app/layout.tsx

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Toast } from "@/components/Toast";
export const metadata: Metadata = {
title: "Node Banana - AI Image Workflow",
@ -15,6 +16,7 @@ export default function RootLayout({
<html lang="en">
<body className="antialiased">
{children}
<Toast />
</body>
</html>
);

242
src/components/AnnotationModal.tsx

@ -53,6 +53,7 @@ export function AnnotationModal() {
const stageRef = useRef<Konva.Stage>(null);
const transformerRef = useRef<Konva.Transformer>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [scale, setScale] = useState(1);
@ -61,6 +62,9 @@ export function AnnotationModal() {
const [drawStart, setDrawStart] = useState({ x: 0, y: 0 });
const [currentShape, setCurrentShape] = useState<AnnotationShape | null>(null);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [textInputPosition, setTextInputPosition] = useState<{ x: number; y: number } | null>(null);
const [pendingTextPosition, setPendingTextPosition] = useState<{ x: number; y: number } | null>(null);
const textInputCreatedAt = useRef<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -109,6 +113,8 @@ export function AnnotationModal() {
if (e.key === "Escape") {
if (editingTextId) {
setEditingTextId(null);
setTextInputPosition(null);
setPendingTextPosition(null);
} else {
closeModal();
}
@ -176,17 +182,30 @@ export function AnnotationModal() {
case "freehand":
newShape = { ...baseShape, type: "freehand", points: [0, 0] } as FreehandShape;
break;
case "text":
newShape = { ...baseShape, type: "text", text: "Text", fontSize: toolOptions.fontSize, fill: toolOptions.strokeColor } as TextShape;
addAnnotation(newShape);
setEditingTextId(id);
case "text": {
// Calculate screen position for the input
const stage = stageRef.current;
if (stage) {
const container = stage.container();
const stageBox = container?.getBoundingClientRect();
if (stageBox) {
const screenX = stageBox.left + pos.x * scale + position.x;
const screenY = stageBox.top + pos.y * scale + position.y;
setTextInputPosition({ x: screenX, y: screenY });
setPendingTextPosition({ x: pos.x, y: pos.y });
}
}
textInputCreatedAt.current = Date.now();
setEditingTextId("new");
setIsDrawing(false);
setTimeout(() => textInputRef.current?.focus(), 0);
return;
}
}
if (newShape) setCurrentShape(newShape);
},
[currentTool, toolOptions, getRelativePointerPosition, selectShape, addAnnotation]
[currentTool, toolOptions, getRelativePointerPosition, selectShape, addAnnotation, scale, position]
);
const handleMouseMove = useCallback(() => {
@ -336,7 +355,44 @@ export function AnnotationModal() {
}
case "text": {
const text = shape as TextShape;
return <Text key={shape.id} {...commonProps} x={text.x} y={text.y} text={text.text} fontSize={text.fontSize} fill={text.fill} onDblClick={() => { if (currentTool === "select") setEditingTextId(shape.id); }} />;
return (
<Text
key={shape.id}
{...commonProps}
x={text.x}
y={text.y}
text={text.text || " "}
fontSize={text.fontSize}
fill={text.fill}
onTransformEnd={(e) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Reset scale and apply it to fontSize instead
node.scaleX(1);
node.scaleY(1);
const newFontSize = Math.round(text.fontSize * Math.max(scaleX, scaleY));
updateAnnotation(shape.id, {
x: node.x(),
y: node.y(),
fontSize: newFontSize,
});
}}
onDblClick={() => {
if (currentTool === "select") {
const stage = stageRef.current;
if (stage) {
const stageBox = stage.container().getBoundingClientRect();
const screenX = stageBox.left + text.x * scale + position.x;
const screenY = stageBox.top + text.y * scale + position.y;
setTextInputPosition({ x: screenX, y: screenY });
}
setEditingTextId(shape.id);
setTimeout(() => textInputRef.current?.focus(), 0);
}
}}
/>
);
}
}
};
@ -355,13 +411,13 @@ export function AnnotationModal() {
return (
<div className="fixed inset-0 z-[100] bg-neutral-950 flex flex-col">
{/* Top Bar */}
<div className="h-10 bg-neutral-900 flex items-center justify-between px-3 border-b border-neutral-800">
<div className="flex items-center gap-1">
<div className="h-14 bg-neutral-900 flex items-center justify-between px-4 border-b border-neutral-800">
<div className="flex items-center gap-1.5">
{tools.map((tool) => (
<button
key={tool.type}
onClick={() => setCurrentTool(tool.type)}
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-colors ${
className={`px-3.5 py-1.5 text-xs font-medium rounded transition-colors ${
currentTool === tool.type
? "bg-white text-neutral-900"
: "text-neutral-400 hover:text-white"
@ -371,21 +427,21 @@ export function AnnotationModal() {
</button>
))}
<div className="w-px h-4 bg-neutral-700 mx-2" />
<div className="w-px h-6 bg-neutral-700 mx-3" />
<button onClick={undo} className="px-2 py-1 text-[10px] text-neutral-400 hover:text-white">Undo</button>
<button onClick={redo} className="px-2 py-1 text-[10px] text-neutral-400 hover:text-white">Redo</button>
<button onClick={undo} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-white">Undo</button>
<button onClick={redo} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-white">Redo</button>
<div className="w-px h-4 bg-neutral-700 mx-2" />
<div className="w-px h-6 bg-neutral-700 mx-3" />
<button onClick={clearAnnotations} className="px-2 py-1 text-[10px] text-neutral-400 hover:text-red-400">Clear</button>
<button onClick={clearAnnotations} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-red-400">Clear</button>
</div>
<div className="flex items-center gap-2">
<button onClick={closeModal} className="px-3 py-1 text-[10px] font-medium text-neutral-400 hover:text-white">
<div className="flex items-center gap-3">
<button onClick={closeModal} className="px-4 py-1.5 text-xs font-medium text-neutral-400 hover:text-white">
Cancel
</button>
<button onClick={handleDone} className="px-3 py-1 text-[10px] font-medium bg-white text-neutral-900 rounded hover:bg-neutral-200">
<button onClick={handleDone} className="px-4 py-1.5 text-xs font-medium bg-white text-neutral-900 rounded hover:bg-neutral-200">
Done
</button>
</div>
@ -419,32 +475,32 @@ export function AnnotationModal() {
</div>
{/* Bottom Options Bar */}
<div className="h-10 bg-neutral-900 flex items-center justify-center gap-4 px-3 border-t border-neutral-800">
<div className="h-14 bg-neutral-900 flex items-center justify-center gap-6 px-4 border-t border-neutral-800">
{/* Colors */}
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wide mr-1">Color</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-neutral-500 uppercase tracking-wide mr-1">Color</span>
{COLORS.map((color) => (
<button
key={color}
onClick={() => setToolOptions({ strokeColor: color })}
className={`w-4 h-4 rounded-full transition-transform ${
toolOptions.strokeColor === color ? "ring-1 ring-white ring-offset-1 ring-offset-neutral-900 scale-110" : "hover:scale-105"
className={`w-6 h-6 rounded-full transition-transform ${
toolOptions.strokeColor === color ? "ring-2 ring-white ring-offset-2 ring-offset-neutral-900 scale-110" : "hover:scale-105"
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="w-px h-4 bg-neutral-700" />
<div className="w-px h-6 bg-neutral-700" />
{/* Stroke Width */}
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wide mr-1">Size</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-neutral-500 uppercase tracking-wide mr-1">Size</span>
{STROKE_WIDTHS.map((width) => (
<button
key={width}
onClick={() => setToolOptions({ strokeWidth: width })}
className={`w-6 h-6 rounded flex items-center justify-center transition-colors ${
className={`w-8 h-8 rounded flex items-center justify-center transition-colors ${
toolOptions.strokeWidth === width ? "bg-neutral-700" : "hover:bg-neutral-800"
}`}
>
@ -453,12 +509,12 @@ export function AnnotationModal() {
))}
</div>
<div className="w-px h-4 bg-neutral-700" />
<div className="w-px h-6 bg-neutral-700" />
{/* Fill Toggle */}
<button
onClick={() => setToolOptions({ fillColor: toolOptions.fillColor ? null : toolOptions.strokeColor })}
className={`px-2 py-1 text-[9px] uppercase tracking-wide rounded transition-colors ${
className={`px-3 py-1.5 text-[10px] uppercase tracking-wide rounded transition-colors ${
toolOptions.fillColor ? "bg-neutral-700 text-white" : "text-neutral-500 hover:text-white"
}`}
>
@ -466,42 +522,106 @@ export function AnnotationModal() {
</button>
{/* Zoom */}
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => setScale(Math.max(scale - 0.1, 0.1))} className="w-5 h-5 rounded text-neutral-400 hover:text-white text-xs">-</button>
<span className="text-[9px] text-neutral-400 w-8 text-center">{Math.round(scale * 100)}%</span>
<button onClick={() => setScale(Math.min(scale + 0.1, 5))} className="w-5 h-5 rounded text-neutral-400 hover:text-white text-xs">+</button>
<div className="flex items-center gap-2 ml-auto">
<button onClick={() => setScale(Math.max(scale - 0.1, 0.1))} className="w-7 h-7 rounded text-neutral-400 hover:text-white text-sm">-</button>
<span className="text-[10px] text-neutral-400 w-10 text-center">{Math.round(scale * 100)}%</span>
<button onClick={() => setScale(Math.min(scale + 0.1, 5))} className="w-7 h-7 rounded text-neutral-400 hover:text-white text-sm">+</button>
</div>
</div>
{/* Text Editing Modal */}
{editingTextId && (
<div className="fixed inset-0 z-[110] bg-black/60 flex items-center justify-center">
<div className="bg-white rounded-md p-3 w-72">
<input
type="text"
autoFocus
defaultValue={(annotations.find((a) => a.id === editingTextId) as TextShape)?.text || ""}
className="w-full px-2 py-1.5 text-sm text-gray-900 border border-gray-200 rounded mb-3 focus:outline-none focus:ring-1 focus:ring-gray-300"
onKeyDown={(e) => {
if (e.key === "Enter") { updateAnnotation(editingTextId, { text: (e.target as HTMLInputElement).value }); setEditingTextId(null); }
if (e.key === "Escape") setEditingTextId(null);
}}
/>
<div className="flex justify-end gap-2">
<button onClick={() => setEditingTextId(null)} className="px-2 py-1 text-[10px] text-gray-500 hover:text-gray-900">Cancel</button>
<button
onClick={() => {
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
if (input) updateAnnotation(editingTextId, { text: input.value });
setEditingTextId(null);
}}
className="px-2 py-1 text-[10px] bg-gray-900 text-white rounded hover:bg-gray-800"
>
Save
</button>
</div>
</div>
</div>
{/* Inline Text Input */}
{editingTextId && textInputPosition && (
<input
ref={textInputRef}
type="text"
autoFocus
defaultValue={editingTextId === "new" ? "" : (annotations.find((a) => a.id === editingTextId) as TextShape)?.text || ""}
className="fixed z-[110] bg-transparent border-none outline-none"
style={{
left: textInputPosition.x,
top: textInputPosition.y,
fontSize: `${toolOptions.fontSize * scale}px`,
color: editingTextId === "new" ? toolOptions.strokeColor : ((annotations.find((a) => a.id === editingTextId) as TextShape)?.fill || toolOptions.strokeColor),
minWidth: "100px",
caretColor: "white",
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const value = (e.target as HTMLInputElement).value;
if (value.trim()) {
if (editingTextId === "new" && pendingTextPosition) {
// Create new text annotation
const newShape: TextShape = {
id: `shape-${Date.now()}`,
type: "text",
x: pendingTextPosition.x,
y: pendingTextPosition.y,
text: value,
fontSize: toolOptions.fontSize,
fill: toolOptions.strokeColor,
stroke: toolOptions.strokeColor,
strokeWidth: toolOptions.strokeWidth,
opacity: toolOptions.opacity,
};
addAnnotation(newShape);
} else {
updateAnnotation(editingTextId, { text: value });
}
} else if (editingTextId !== "new") {
deleteAnnotation(editingTextId);
}
setEditingTextId(null);
setTextInputPosition(null);
setPendingTextPosition(null);
}
if (e.key === "Escape") {
if (editingTextId !== "new") {
const currentText = (annotations.find((a) => a.id === editingTextId) as TextShape)?.text;
if (!currentText) {
deleteAnnotation(editingTextId);
}
}
setEditingTextId(null);
setTextInputPosition(null);
setPendingTextPosition(null);
}
}}
onBlur={(e) => {
// Ignore blur events that happen immediately after creation (within 200ms)
// This prevents the click that created the input from also triggering blur
if (Date.now() - textInputCreatedAt.current < 200) {
e.target.focus();
return;
}
const value = e.target.value;
if (value.trim()) {
if (editingTextId === "new" && pendingTextPosition) {
// Create new text annotation
const newShape: TextShape = {
id: `shape-${Date.now()}`,
type: "text",
x: pendingTextPosition.x,
y: pendingTextPosition.y,
text: value,
fontSize: toolOptions.fontSize,
fill: toolOptions.strokeColor,
stroke: toolOptions.strokeColor,
strokeWidth: toolOptions.strokeWidth,
opacity: toolOptions.opacity,
};
addAnnotation(newShape);
} else {
updateAnnotation(editingTextId, { text: value });
}
} else if (editingTextId !== "new") {
deleteAnnotation(editingTextId);
}
setEditingTextId(null);
setTextInputPosition(null);
setPendingTextPosition(null);
}}
/>
)}
</div>
);

101
src/components/EdgeToolbar.tsx

@ -0,0 +1,101 @@
"use client";
import { useWorkflowStore } from "@/store/workflowStore";
import { useMemo, useEffect, useState, useRef } from "react";
export function EdgeToolbar() {
const { edges, toggleEdgePause, removeEdge } = useWorkflowStore();
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
const previousSelectedEdgeId = useRef<string | null>(null);
const selectedEdge = useMemo(
() => edges.find((edge) => edge.selected),
[edges]
);
// Track mouse position when edge selection changes
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Check if clicking on an edge
const target = e.target as Element;
if (target.closest('.react-flow__edge')) {
setClickPosition({ x: e.clientX, y: e.clientY - 40 }); // 40px above click
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, []);
// Reset click position when edge is deselected
useEffect(() => {
if (!selectedEdge && previousSelectedEdgeId.current) {
setClickPosition(null);
}
previousSelectedEdgeId.current = selectedEdge?.id || null;
}, [selectedEdge]);
const toolbarPosition = clickPosition;
const handleTogglePause = () => {
if (selectedEdge) {
toggleEdgePause(selectedEdge.id);
}
};
const handleDelete = () => {
if (selectedEdge) {
removeEdge(selectedEdge.id);
}
};
if (!toolbarPosition || !selectedEdge) return null;
const hasPause = selectedEdge.data?.hasPause;
return (
<div
className="fixed z-[100] flex items-center gap-1 bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-1"
style={{
left: toolbarPosition.x,
top: toolbarPosition.y,
transform: "translateX(-50%)",
}}
>
<button
onClick={handleTogglePause}
className={`p-1.5 rounded hover:bg-neutral-700 transition-colors ${
hasPause
? "text-amber-400 hover:text-amber-300"
: "text-neutral-400 hover:text-neutral-100"
}`}
title={hasPause ? "Remove pause" : "Add pause"}
>
{hasPause ? (
// Play icon (resume)
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
// Pause icon
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
<button
onClick={handleDelete}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-red-400 transition-colors"
title="Delete"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
);
}

194
src/components/FloatingActionBar.tsx

@ -1,6 +1,6 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, useMemo } from "react";
import { useWorkflowStore, EdgeStyle, WorkflowFile } from "@/store/workflowStore";
import { NodeType } from "@/types";
import { useReactFlow } from "@xyflow/react";
@ -133,12 +133,37 @@ function GenerateComboButton() {
}
export function FloatingActionBar() {
const { isRunning, executeWorkflow, stopWorkflow, validateWorkflow, edgeStyle, setEdgeStyle, saveWorkflow, loadWorkflow } =
const { nodes, isRunning, executeWorkflow, regenerateNode, stopWorkflow, validateWorkflow, edgeStyle, setEdgeStyle, saveWorkflow, loadWorkflow } =
useWorkflowStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const runMenuRef = useRef<HTMLDivElement>(null);
const { valid, errors } = validateWorkflow();
// Get the selected node (if exactly one is selected)
const selectedNode = useMemo(() => {
const selected = nodes.filter((n) => n.selected);
return selected.length === 1 ? selected[0] : null;
}, [nodes]);
// Close run menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node)) {
setRunMenuOpen(false);
}
};
if (runMenuOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [runMenuOpen]);
const toggleEdgeStyle = () => {
setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular");
};
@ -151,6 +176,20 @@ export function FloatingActionBar() {
}
};
const handleRunFromSelected = () => {
if (selectedNode) {
executeWorkflow(selectedNode.id);
setRunMenuOpen(false);
}
};
const handleRunSelectedOnly = () => {
if (selectedNode) {
regenerateNode(selectedNode.id);
setRunMenuOpen(false);
}
};
const handleSave = () => {
saveWorkflow();
};
@ -240,54 +279,123 @@ export function FloatingActionBar() {
<div className="w-px h-5 bg-neutral-600 mx-1.5" />
<button
onClick={handleRunClick}
disabled={!valid && !isRunning}
title={!valid ? errors.join("\n") : isRunning ? "Stop" : "Run"}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-[11px] font-medium transition-colors ${
isRunning
? "bg-white text-neutral-900 hover:bg-neutral-200"
: valid
? "bg-white text-neutral-900 hover:bg-neutral-200"
: "bg-neutral-700 text-neutral-500 cursor-not-allowed"
}`}
>
{isRunning ? (
<>
<div className="relative flex items-center" ref={runMenuRef}>
<button
onClick={handleRunClick}
disabled={!valid && !isRunning}
title={!valid ? errors.join("\n") : isRunning ? "Stop" : "Run"}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium transition-colors ${
isRunning
? "bg-white text-neutral-900 hover:bg-neutral-200 rounded"
: valid
? "bg-white text-neutral-900 hover:bg-neutral-200 rounded-l"
: "bg-neutral-700 text-neutral-500 cursor-not-allowed rounded"
}`}
>
{isRunning ? (
<>
<svg
className="w-3 h-3 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Stop</span>
</>
) : (
<>
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
<span>Run</span>
</>
)}
</button>
{/* Dropdown chevron button */}
{!isRunning && valid && (
<button
onClick={() => setRunMenuOpen(!runMenuOpen)}
className="flex items-center self-stretch px-1.5 rounded-r bg-white text-neutral-900 hover:bg-neutral-200 border-l border-neutral-200 transition-colors"
title="Run options"
>
<svg
className="w-3 h-3 animate-spin"
className={`w-2.5 h-2.5 transition-transform ${runMenuOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
<span>Stop</span>
</>
) : (
<>
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 24 24"
</button>
)}
{/* Dropdown menu */}
{runMenuOpen && !isRunning && (
<div className="absolute bottom-full right-0 mb-2 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl overflow-hidden min-w-[180px]">
<button
onClick={() => {
executeWorkflow();
setRunMenuOpen(false);
}}
className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2"
>
<path d="M8 5v14l11-7z" />
</svg>
<span>Run</span>
</>
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
Run entire workflow
</button>
<button
onClick={handleRunFromSelected}
disabled={!selectedNode}
className={`w-full px-3 py-2 text-left text-[11px] font-medium transition-colors flex items-center gap-2 ${
selectedNode
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
: "text-neutral-500 cursor-not-allowed"
}`}
title={!selectedNode ? "Select a single node first" : undefined}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
Run from selected node
</button>
<button
onClick={handleRunSelectedOnly}
disabled={!selectedNode}
className={`w-full px-3 py-2 text-left text-[11px] font-medium transition-colors flex items-center gap-2 ${
selectedNode
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
: "text-neutral-500 cursor-not-allowed"
}`}
title={!selectedNode ? "Select a single node first" : undefined}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
Run selected node only
</button>
</div>
)}
</button>
</div>
</div>
</div>
);

82
src/components/Toast.tsx

@ -0,0 +1,82 @@
"use client";
import { useEffect } from "react";
import { create } from "zustand";
interface ToastState {
message: string | null;
type: "info" | "success" | "warning" | "error";
show: (message: string, type?: "info" | "success" | "warning" | "error") => void;
hide: () => void;
}
export const useToast = create<ToastState>((set) => ({
message: null,
type: "info",
show: (message, type = "info") => set({ message, type }),
hide: () => set({ message: null }),
}));
const typeStyles = {
info: "bg-neutral-800 border-neutral-600 text-neutral-100",
success: "bg-green-900 border-green-700 text-green-100",
warning: "bg-orange-900 border-orange-600 text-orange-100",
error: "bg-red-900 border-red-700 text-red-100",
};
const typeIcons = {
info: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
success: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
export function Toast() {
const { message, type, hide } = useToast();
useEffect(() => {
if (message) {
const timer = setTimeout(() => {
hide();
}, 4000);
return () => clearTimeout(timer);
}
}, [message, hide]);
if (!message) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] animate-in fade-in slide-in-from-bottom-4 duration-300">
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-xl ${typeStyles[type]}`}
>
{typeIcons[type]}
<span className="text-sm font-medium">{message}</span>
<button
onClick={hide}
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}

147
src/components/WorkflowCanvas.tsx

@ -28,6 +28,7 @@ import {
import { EditableEdge } from "./edges";
import { ConnectionDropMenu } from "./ConnectionDropMenu";
import { MultiSelectToolbar } from "./MultiSelectToolbar";
import { EdgeToolbar } from "./EdgeToolbar";
import { NodeType } from "@/types";
const nodeTypes: NodeTypes = {
@ -119,33 +120,109 @@ function WorkflowCanvasInner() {
[onConnect, nodes]
);
// Handle connection dropped on empty space
// Define which handles each node type has
const getNodeHandles = useCallback((nodeType: string): { inputs: string[]; outputs: string[] } => {
switch (nodeType) {
case "imageInput":
return { inputs: [], outputs: ["image"] };
case "annotation":
return { inputs: ["image"], outputs: ["image"] };
case "prompt":
return { inputs: [], outputs: ["text"] };
case "nanoBanana":
return { inputs: ["image", "text"], outputs: ["image"] };
case "llmGenerate":
return { inputs: ["text", "image"], outputs: ["text"] };
case "output":
return { inputs: ["image"], outputs: [] };
default:
return { inputs: [], outputs: [] };
}
}, []);
// Handle connection dropped on empty space or on a node
const handleConnectEnd: OnConnectEnd = useCallback(
(event, connectionState) => {
// Only show menu if connection was not completed (dropped on empty space)
if (!connectionState.isValid && connectionState.fromNode) {
const { clientX, clientY } = event as MouseEvent;
// Get the handle type from the connection state
const handleId = connectionState.fromHandle?.id || null;
const handleType = (handleId === "image" || handleId === "text") ? handleId : null;
// Determine if we're dragging from a source or target handle
const connectionType = connectionState.fromHandle?.type === "source" ? "source" : "target";
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
setConnectionDrop({
position: { x: clientX, y: clientY },
flowPosition: flowPos,
handleType,
connectionType,
sourceNodeId: connectionState.fromNode.id,
sourceHandleId: handleId,
});
// If connection was completed normally, nothing to do
if (connectionState.isValid || !connectionState.fromNode) {
return;
}
const { clientX, clientY } = event as MouseEvent;
const fromHandleId = connectionState.fromHandle?.id || null;
const fromHandleType = (fromHandleId === "image" || fromHandleId === "text") ? fromHandleId : null;
const isFromSource = connectionState.fromHandle?.type === "source";
// Check if we dropped on a node by looking for node elements under the cursor
const elementsUnderCursor = document.elementsFromPoint(clientX, clientY);
const nodeElement = elementsUnderCursor.find((el) => {
// React Flow nodes have data-id attribute
return el.closest(".react-flow__node");
});
if (nodeElement) {
const nodeWrapper = nodeElement.closest(".react-flow__node") as HTMLElement;
const targetNodeId = nodeWrapper?.dataset.id;
if (targetNodeId && targetNodeId !== connectionState.fromNode.id && fromHandleType) {
const targetNode = nodes.find((n) => n.id === targetNodeId);
if (targetNode) {
const targetHandles = getNodeHandles(targetNode.type || "");
// Find a compatible handle on the target node
let compatibleHandle: string | null = null;
if (isFromSource) {
// Dragging from output, need an input on target that matches type
if (targetHandles.inputs.includes(fromHandleType)) {
compatibleHandle = fromHandleType;
}
} else {
// Dragging from input, need an output on target that matches type
if (targetHandles.outputs.includes(fromHandleType)) {
compatibleHandle = fromHandleType;
}
}
if (compatibleHandle) {
// Create the connection
const connection: Connection = isFromSource
? {
source: connectionState.fromNode.id,
sourceHandle: fromHandleId,
target: targetNodeId,
targetHandle: compatibleHandle,
}
: {
source: targetNodeId,
sourceHandle: compatibleHandle,
target: connectionState.fromNode.id,
targetHandle: fromHandleId,
};
if (isValidConnection(connection)) {
handleConnect(connection);
return; // Connection made, don't show menu
}
}
}
}
}
// No node under cursor or no compatible handle - show the drop menu
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
setConnectionDrop({
position: { x: clientX, y: clientY },
flowPosition: flowPos,
handleType: fromHandleType,
connectionType: isFromSource ? "source" : "target",
sourceNodeId: connectionState.fromNode.id,
sourceHandleId: fromHandleId,
});
},
[screenToFlowPosition]
[screenToFlowPosition, nodes, getNodeHandles, handleConnect]
);
// Handle node selection from drop menu
@ -247,7 +324,10 @@ function WorkflowCanvasInner() {
setConnectionDrop(null);
}, []);
// Keyboard shortcuts for stacking selected nodes
// Get copy/paste functions from store
const { copySelectedNodes, pasteNodes } = useWorkflowStore();
// Keyboard shortcuts for copy/paste and stacking selected nodes
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore if user is typing in an input field
@ -258,6 +338,20 @@ function WorkflowCanvasInner() {
return;
}
// Handle copy (Ctrl/Cmd + C)
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
event.preventDefault();
copySelectedNodes();
return;
}
// Handle paste (Ctrl/Cmd + V)
if ((event.ctrlKey || event.metaKey) && event.key === "v") {
event.preventDefault();
pasteNodes();
return;
}
const selectedNodes = nodes.filter((node) => node.selected);
if (selectedNodes.length < 2) return;
@ -312,7 +406,7 @@ function WorkflowCanvasInner() {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [nodes, onNodesChange]);
}, [nodes, onNodesChange, copySelectedNodes, pasteNodes]);
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
@ -514,6 +608,9 @@ function WorkflowCanvasInner() {
{/* Multi-select toolbar */}
<MultiSelectToolbar />
{/* Edge toolbar */}
<EdgeToolbar />
</div>
);
}

28
src/components/edges/EditableEdge.tsx

@ -9,9 +9,9 @@ import {
useReactFlow,
} from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import { NanoBananaNodeData } from "@/types";
import { NanoBananaNodeData, WorkflowEdgeData } from "@/types";
interface EdgeData {
interface EdgeData extends WorkflowEdgeData {
offsetX?: number;
offsetY?: number;
}
@ -21,6 +21,7 @@ const EDGE_COLORS = {
image: "#10b981", // Green for image connections
prompt: "#3b82f6", // Blue for prompt connections
default: "#94a3b8", // Gray for unknown
pause: "#f97316", // Orange for paused edges
};
export function EditableEdge({
@ -48,6 +49,7 @@ export function EditableEdge({
const edgeData = data as EdgeData | undefined;
const offsetX = edgeData?.offsetX ?? 0;
const offsetY = edgeData?.offsetY ?? 0;
const hasPause = edgeData?.hasPause ?? false;
// Check if target node is a Generate node that's currently loading
const isTargetLoading = useMemo(() => {
@ -59,14 +61,15 @@ export function EditableEdge({
return false;
}, [target, nodes]);
// Determine edge color based on handle type
// Determine edge color based on handle type (orange if paused)
const edgeColor = useMemo(() => {
if (hasPause) return EDGE_COLORS.pause;
// Use source handle to determine color (or target if source is not available)
const handleType = sourceHandleId || targetHandleId;
if (handleType === "image") return EDGE_COLORS.image;
if (handleType === "prompt") return EDGE_COLORS.prompt;
return EDGE_COLORS.default;
}, [sourceHandleId, targetHandleId]);
}, [hasPause, sourceHandleId, targetHandleId]);
// Generate a unique gradient ID for this edge
const gradientId = `pulse-gradient-${id}`;
@ -217,6 +220,23 @@ export function EditableEdge({
stroke="transparent"
className="react-flow__edge-interaction"
/>
{/* Pause indicator near target connection point */}
{hasPause && (
<g transform={`translate(${targetX - 24}, ${targetY})`}>
{/* Background circle */}
<circle
r={10}
fill="#27272a"
stroke={edgeColor}
strokeWidth={2}
/>
{/* Pause bars */}
<rect x={-4} y={-5} width={2.5} height={10} fill={edgeColor} rx={1} />
<rect x={1.5} y={-5} width={2.5} height={10} fill={edgeColor} rx={1} />
</g>
)}
{/* Draggable handles on segments */}
{(selected || isDragging) &&
handlePositions.map((handle, index) => (

2
src/components/nodes/AnnotationNode.tsx

@ -117,7 +117,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
<img
src={displayImage}
alt="Annotated"
className="w-full flex-1 min-h-0 object-cover rounded"
className="w-full flex-1 min-h-0 object-contain rounded"
/>
<button
onClick={(e) => {

2
src/components/nodes/ImageInputNode.tsx

@ -92,7 +92,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
<img
src={nodeData.image}
alt={nodeData.filename || "Uploaded image"}
className="w-full flex-1 min-h-0 object-cover rounded"
className="w-full flex-1 min-h-0 object-contain rounded"
/>
<button
onClick={handleRemove}

2
src/components/nodes/NanoBananaNode.tsx

@ -103,7 +103,7 @@ export function NanoBananaNode({ id, data, selected }: NodeProps<NanoBananaNodeT
<img
src={nodeData.outputImage}
alt="Generated"
className="w-full h-full object-cover rounded"
className="w-full h-full object-contain rounded"
/>
{/* Loading overlay */}
{nodeData.status === "loading" && (

2
src/components/nodes/OutputNode.tsx

@ -41,7 +41,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
<img
src={nodeData.image}
alt="Output"
className="w-full h-full object-cover rounded"
className="w-full h-full object-contain rounded"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center rounded">
<span className="text-[10px] font-medium text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 px-2 py-1 rounded">

2
src/components/nodes/PromptNode.tsx

@ -25,7 +25,7 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
value={nodeData.prompt}
onChange={handleChange}
placeholder="Describe what to generate..."
className="w-full flex-1 min-h-[70px] p-2 text-xs leading-relaxed text-neutral-100 border border-neutral-700 rounded bg-neutral-900/50 resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600 focus:border-neutral-600 placeholder:text-neutral-500"
className="nodrag nopan nowheel w-full flex-1 min-h-[70px] p-2 text-xs leading-relaxed text-neutral-100 border border-neutral-700 rounded bg-neutral-900/50 resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600 focus:border-neutral-600 placeholder:text-neutral-500"
/>
<Handle

149
src/store/workflowStore.ts

@ -20,6 +20,7 @@ import {
OutputNodeData,
WorkflowNodeData,
} from "@/types";
import { useToast } from "@/components/Toast";
export type EdgeStyle = "angular" | "curved";
@ -32,10 +33,17 @@ export interface WorkflowFile {
edgeStyle: EdgeStyle;
}
// Clipboard data structure for copy/paste
interface ClipboardData {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
interface WorkflowStore {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
edgeStyle: EdgeStyle;
clipboard: ClipboardData | null;
// Settings
setEdgeStyle: (style: EdgeStyle) => void;
@ -50,11 +58,17 @@ interface WorkflowStore {
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
onConnect: (connection: Connection) => void;
removeEdge: (edgeId: string) => void;
toggleEdgePause: (edgeId: string) => void;
// Copy/Paste operations
copySelectedNodes: () => void;
pasteNodes: (offset?: XYPosition) => void;
// Execution
isRunning: boolean;
currentNodeId: string | null;
executeWorkflow: () => Promise<void>;
pausedAtNodeId: string | null;
executeWorkflow: (startFromNodeId?: string) => Promise<void>;
regenerateNode: (nodeId: string) => Promise<void>;
stopWorkflow: () => void;
@ -123,8 +137,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
nodes: [],
edges: [],
edgeStyle: "curved" as EdgeStyle,
clipboard: null,
isRunning: false,
currentNodeId: null,
pausedAtNodeId: null,
setEdgeStyle: (style: EdgeStyle) => {
set({ edgeStyle: style });
@ -135,12 +151,12 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
// Default dimensions based on node type
const defaultDimensions: Record<NodeType, { width: number; height: number }> = {
imageInput: { width: 220, height: 200 },
annotation: { width: 220, height: 200 },
prompt: { width: 240, height: 160 },
nanoBanana: { width: 220, height: 220 },
llmGenerate: { width: 240, height: 280 },
output: { width: 240, height: 240 },
imageInput: { width: 300, height: 280 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
nanoBanana: { width: 300, height: 300 },
llmGenerate: { width: 320, height: 360 },
output: { width: 320, height: 320 },
};
const { width, height } = defaultDimensions[type];
@ -209,6 +225,82 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
}));
},
toggleEdgePause: (edgeId: string) => {
set((state) => ({
edges: state.edges.map((edge) =>
edge.id === edgeId
? { ...edge, data: { ...edge.data, hasPause: !edge.data?.hasPause } }
: edge
),
}));
},
copySelectedNodes: () => {
const { nodes, edges } = get();
const selectedNodes = nodes.filter((node) => node.selected);
if (selectedNodes.length === 0) return;
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
// Copy edges that connect selected nodes to each other
const connectedEdges = edges.filter(
(edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target)
);
// Deep clone the nodes and edges to avoid reference issues
const clonedNodes = JSON.parse(JSON.stringify(selectedNodes)) as WorkflowNode[];
const clonedEdges = JSON.parse(JSON.stringify(connectedEdges)) as WorkflowEdge[];
set({ clipboard: { nodes: clonedNodes, edges: clonedEdges } });
},
pasteNodes: (offset: XYPosition = { x: 50, y: 50 }) => {
const { clipboard, nodes, edges } = get();
if (!clipboard || clipboard.nodes.length === 0) return;
// Create a mapping from old node IDs to new node IDs
const idMapping = new Map<string, string>();
// Generate new IDs for all pasted nodes
clipboard.nodes.forEach((node) => {
const newId = `${node.type}-${++nodeIdCounter}`;
idMapping.set(node.id, newId);
});
// Create new nodes with updated IDs and offset positions
const newNodes: WorkflowNode[] = clipboard.nodes.map((node) => ({
...node,
id: idMapping.get(node.id)!,
position: {
x: node.position.x + offset.x,
y: node.position.y + offset.y,
},
selected: true, // Select newly pasted nodes
data: { ...node.data }, // Deep copy data
}));
// Create new edges with updated source/target IDs
const newEdges: WorkflowEdge[] = clipboard.edges.map((edge) => ({
...edge,
id: `edge-${idMapping.get(edge.source)}-${idMapping.get(edge.target)}-${edge.sourceHandle || "default"}-${edge.targetHandle || "default"}`,
source: idMapping.get(edge.source)!,
target: idMapping.get(edge.target)!,
}));
// Deselect existing nodes and add new ones
const updatedNodes = nodes.map((node) => ({
...node,
selected: false,
}));
set({
nodes: [...updatedNodes, ...newNodes] as WorkflowNode[],
edges: [...edges, ...newEdges],
});
},
getNodeById: (id: string) => {
return get().nodes.find((node) => node.id === id);
},
@ -282,12 +374,13 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
}
});
// Check annotation nodes have image input
// Check annotation nodes have image input (either connected or manually loaded)
nodes
.filter((n) => n.type === "annotation")
.forEach((node) => {
const imageConnected = edges.some((e) => e.target === node.id);
if (!imageConnected) {
const hasManualImage = (node.data as AnnotationNodeData).sourceImage !== null;
if (!imageConnected && !hasManualImage) {
errors.push(`Annotation node "${node.id}" missing image input`);
}
});
@ -305,7 +398,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
return { valid: errors.length === 0, errors };
},
executeWorkflow: async () => {
executeWorkflow: async (startFromNodeId?: string) => {
const { nodes, edges, updateNodeData, getConnectedInputs, isRunning } = get();
if (isRunning) {
@ -314,7 +407,11 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
}
console.log(`[Workflow] ========== STARTING WORKFLOW EXECUTION ==========`);
set({ isRunning: true });
const isResuming = startFromNodeId === get().pausedAtNodeId;
if (startFromNodeId) {
console.log(`[Workflow] Starting from node: ${startFromNodeId}${isResuming ? ' (resuming from pause)' : ''}`);
}
set({ isRunning: true, pausedAtNodeId: null });
// Topological sort
const sorted: WorkflowNode[] = [];
@ -344,10 +441,36 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
try {
nodes.forEach((node) => visit(node.id));
// Execute nodes in order
for (const node of sorted) {
// If starting from a specific node, find its index and skip earlier nodes
let startIndex = 0;
if (startFromNodeId) {
const nodeIndex = sorted.findIndex((n) => n.id === startFromNodeId);
if (nodeIndex !== -1) {
startIndex = nodeIndex;
console.log(`[Workflow] Skipping ${startIndex} nodes, starting at index ${startIndex}`);
} else {
console.warn(`[Workflow] Start node ${startFromNodeId} not found in sorted list`);
}
}
// Execute nodes in order, starting from startIndex
for (let i = startIndex; i < sorted.length; i++) {
const node = sorted[i];
if (!get().isRunning) break;
// Check for pause edges on incoming connections (skip if resuming from this exact node)
const isResumingThisNode = isResuming && node.id === startFromNodeId;
if (!isResumingThisNode) {
const incomingEdges = edges.filter((e) => e.target === node.id);
const pauseEdge = incomingEdges.find((e) => e.data?.hasPause);
if (pauseEdge) {
console.log(`[Workflow] ⏸ Paused at edge before node: ${node.id}`);
set({ pausedAtNodeId: node.id, isRunning: false, currentNodeId: null });
useToast.getState().show("Workflow paused - click Run to continue", "warning");
return;
}
}
set({ currentNodeId: node.id });
switch (node.type) {

7
src/types/index.ts

@ -148,8 +148,13 @@ export type WorkflowNodeData =
// Workflow Node with typed data
export type WorkflowNode = Node<WorkflowNodeData, NodeType>;
// Workflow Edge Data
export interface WorkflowEdgeData extends Record<string, unknown> {
hasPause?: boolean;
}
// Workflow Edge
export type WorkflowEdge = Edge;
export type WorkflowEdge = Edge<WorkflowEdgeData>;
// Handle Types for connections
export type HandleType = "image" | "text";

Loading…
Cancel
Save