mirror of https://github.com/kortix-ai/suna.git
Merge branch 'extend-workflows-ui' into fix-ux-issues
This commit is contained in:
commit
1e501a12be
|
@ -31,6 +31,7 @@ export interface ConditionalStep {
|
|||
order: number;
|
||||
enabled?: boolean;
|
||||
hasIssues?: boolean;
|
||||
position?: { x: number; y: number }; // Added for storing node positions
|
||||
}
|
||||
|
||||
interface ConditionalWorkflowBuilderProps {
|
||||
|
|
|
@ -1,357 +1,156 @@
|
|||
/* React Flow Workflow Builder Styles */
|
||||
/* React Flow Workflow Builder - Minimalist Vercel-style Design */
|
||||
|
||||
/* Container and Background */
|
||||
.react-flow-container {
|
||||
background: hsl(var(--background));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Base Node Styles */
|
||||
.react-flow__node {
|
||||
width: 240px;
|
||||
width: 280px;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card)) !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.react-flow__node:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.react-flow__node:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.react-flow__node.selected {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .react-flow__node {
|
||||
border: 1px solid #374151;
|
||||
background: #1f2937 !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card)) !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .react-flow__node:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Step Node Specific */
|
||||
.react-flow__node-step {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff !important;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card)) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__node-step {
|
||||
border: 1px solid #374151;
|
||||
background: #1f2937 !important;
|
||||
}
|
||||
|
||||
.react-flow__node-step.selected {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
.react-flow__node-step.has-issues {
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Condition Node Specific */
|
||||
.react-flow__node-condition {
|
||||
width: 140px;
|
||||
height: 80px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__node-condition:hover {
|
||||
background: rgba(245, 158, 11, 0.15) !important;
|
||||
width: 220px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3) !important;
|
||||
}
|
||||
|
||||
.react-flow__node-condition.selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2);
|
||||
border-style: solid;
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--muted) / 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__node-condition {
|
||||
border-color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__node-condition:hover {
|
||||
background: rgba(245, 158, 11, 0.2) !important;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow {
|
||||
stroke: hsl(var(--muted-foreground));
|
||||
stroke-width: 2;
|
||||
transition: stroke 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow:hover {
|
||||
stroke: hsl(var(--foreground));
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow.selected {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Arrow marker styling */
|
||||
.react-flow__arrowhead {
|
||||
fill: hsl(var(--muted-foreground));
|
||||
transition: fill 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow:hover .react-flow__arrowhead {
|
||||
fill: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow.selected .react-flow__arrowhead {
|
||||
fill: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.edge-button {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.edge-button:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dark .edge-button {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #9ca3af;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark .edge-button:hover {
|
||||
background: #374151;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Node content styles */
|
||||
.workflow-node-content {
|
||||
/* Node Content */
|
||||
.node-content {
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-node-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workflow-node-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.workflow-node-title {
|
||||
.node-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dark .workflow-node-title {
|
||||
color: #f9fafb;
|
||||
.node-description {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.workflow-node-description {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .workflow-node-description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-node-tool {
|
||||
display: flex;
|
||||
.node-tool-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #f1f5f9;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
background: hsl(var(--muted));
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.dark .workflow-node-tool {
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
/* Handles */
|
||||
.react-flow__handle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: hsl(var(--primary));
|
||||
border: 2px solid hsl(var(--background));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.workflow-node-tool-icon {
|
||||
.react-flow__handle:hover {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.workflow-node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
.react-flow__node:hover .react-flow__handle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.react-flow__node:hover .workflow-node-actions {
|
||||
opacity: 1;
|
||||
/* Edges */
|
||||
.react-flow__edge-path {
|
||||
stroke: hsl(var(--border));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.workflow-node-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
.react-flow__edge.selected .react-flow__edge-path {
|
||||
stroke: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.dark .workflow-node-status {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.workflow-node-status.has-issues {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.workflow-node-status.completed {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.condition-node-content {
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.react-flow__edge-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.condition-node-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.condition-node-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.condition-node-expression {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .condition-node-content {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Panel styling */
|
||||
.react-flow__panel {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dark .react-flow__panel {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.workflow-panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workflow-panel-selection {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Background styling */
|
||||
.react-flow__background {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.dark .react-flow__background {
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
/* Controls styling */
|
||||
/* Controls */
|
||||
.react-flow__controls {
|
||||
background: hsl(var(--card));
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .react-flow__controls {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background: hsl(var(--card));
|
||||
border: none;
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
|
@ -359,28 +158,225 @@
|
|||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.react-flow__controls-button:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.react-flow__panel {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.workflow-panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: hsl(var(--background) / 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.workflow-panel-selection {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border: 1px solid hsl(var(--primary) / 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Background */
|
||||
.react-flow__background-pattern {
|
||||
stroke: hsl(var(--border) / 0.3);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-primary {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px hsl(var(--primary) / 0); }
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-node-pulse {
|
||||
animation: pulse-primary 2s infinite;
|
||||
.react-flow__node {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.react-flow__node {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.react-flow__node-condition {
|
||||
width: 120px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.workflow-node-content {
|
||||
padding: 12px;
|
||||
}
|
||||
/* Node Actions */
|
||||
.node-actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__node:hover .node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Drag Preview */
|
||||
.react-flow__node.dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Selection Box */
|
||||
.react-flow__selection {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
border: 1px solid hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Minimap */
|
||||
.react-flow__minimap {
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.react-flow__minimap-mask {
|
||||
fill: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.react-flow__minimap-node {
|
||||
fill: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Condition Node Styles */
|
||||
.condition-node-content {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.condition-type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.condition-type-if {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.condition-type-elseif {
|
||||
background: hsl(var(--warning) / 0.1);
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.condition-type-else {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.condition-expression {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
font-style: italic;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.workflow-empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.workflow-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.node-error {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: hsl(var(--destructive));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.workflow-tooltip {
|
||||
position: absolute;
|
||||
background: hsl(var(--popover));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--popover-foreground));
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Edge Button */
|
||||
.edge-button {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.edge-button:hover {
|
||||
background: hsl(var(--accent));
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.edge-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Edge styles */
|
||||
.react-flow__edge-workflow .react-flow__edge-path {
|
||||
stroke: hsl(var(--border));
|
||||
stroke-width: 2;
|
||||
transition: stroke 0.2s ease;
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow:hover .react-flow__edge-path {
|
||||
stroke: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.react-flow__edge-workflow.selected .react-flow__edge-path {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 2.5;
|
||||
}
|
|
@ -17,6 +17,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getConditionLabel } from '../utils';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
|
||||
|
@ -50,20 +51,36 @@ const ConditionNode = ({ id, data, selected }: any) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`react-flow__node-condition group ${selected ? 'selected' : ''}`}
|
||||
className={cn(
|
||||
"react-flow__node-condition group",
|
||||
selected && "selected"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} />
|
||||
<div className="condition-node-content flex-col relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{getConditionLabel(data.conditionType)}</span>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="react-flow__handle"
|
||||
/>
|
||||
<div className="condition-node-content">
|
||||
<div className={cn(
|
||||
"condition-type-badge",
|
||||
data.conditionType === 'if' && "condition-type-if",
|
||||
data.conditionType === 'elseif' && "condition-type-elseif",
|
||||
data.conditionType === 'else' && "condition-type-else"
|
||||
)}>
|
||||
{getConditionLabel(data.conditionType)}
|
||||
</div>
|
||||
{data.expression && data.conditionType !== 'else' && (
|
||||
<div className="text-[10px] mt-0.5 text-amber-800 truncate max-w-[100px]" title={data.expression}>
|
||||
{data.expression}
|
||||
<div className="condition-expression" title={data.expression}>
|
||||
"{data.expression}"
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -top-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-white rounded shadow-sm p-1">
|
||||
{!data.expression && data.conditionType !== 'else' && (
|
||||
<div className="condition-expression text-muted-foreground">
|
||||
No condition set
|
||||
</div>
|
||||
)}
|
||||
<div className="node-actions">
|
||||
<Popover open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
@ -161,7 +178,11 @@ const ConditionNode = ({ id, data, selected }: any) => {
|
|||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="react-flow__handle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -230,28 +230,30 @@ const StepNode = ({ id, data, selected }: any) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`react-flow__node-step ${selected ? 'selected' : ''}`}
|
||||
className={cn(
|
||||
"react-flow__node-step",
|
||||
selected && "selected",
|
||||
data.hasIssues && "has-issues"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} />
|
||||
<div className="workflow-node-content">
|
||||
<div className="flex items-start justify-between">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="react-flow__handle"
|
||||
/>
|
||||
<div className="node-content">
|
||||
<div className="node-header">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{data.hasIssues && (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive flex-shrink-0" />
|
||||
)}
|
||||
<h3 className="font-medium text-sm">
|
||||
{data.name} {data.stepNumber ? data.stepNumber : ''}
|
||||
<h3 className="node-title">
|
||||
{data.name || 'Unnamed Step'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{data.description}</p>
|
||||
{data.tool && (
|
||||
<div className="text-xs text-primary mt-2">
|
||||
🔧 {getDisplayToolName(data.tool)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="node-actions">
|
||||
<Popover open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
@ -468,8 +470,30 @@ const StepNode = ({ id, data, selected }: any) => {
|
|||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Description */}
|
||||
{data.description && (
|
||||
<p className="node-description">{data.description}</p>
|
||||
)}
|
||||
|
||||
{/* Tool Badge */}
|
||||
{data.tool && (
|
||||
<div className="node-tool-badge">
|
||||
<span className="text-xs">🔧</span>
|
||||
<span>{getDisplayToolName(data.tool)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error indicator */}
|
||||
{data.hasIssues && (
|
||||
<div className="node-error">Please configure this step</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="react-flow__handle"
|
||||
/>
|
||||
|
||||
{/* Pipedream Registry Dialog */}
|
||||
<Dialog open={showPipedreamRegistry} onOpenChange={setShowPipedreamRegistry}>
|
||||
|
|
|
@ -59,7 +59,7 @@ export function convertWorkflowToReactFlow(steps: ConditionalStep[]): Conversion
|
|||
const node: Node = {
|
||||
id: nodeId,
|
||||
type: 'step',
|
||||
position: { x: xOffset, y: currentY },
|
||||
position: step.position || { x: xOffset, y: currentY },
|
||||
data: {
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
|
@ -155,7 +155,7 @@ export function convertWorkflowToReactFlow(steps: ConditionalStep[]): Conversion
|
|||
const conditionNode: Node = {
|
||||
id: conditionNodeId,
|
||||
type: 'condition',
|
||||
position: { x: branchXPos, y: yOffset + 100 },
|
||||
position: condition.position || { x: branchXPos, y: yOffset + 100 },
|
||||
data: {
|
||||
conditionType: condition.conditions?.type || 'if',
|
||||
expression: condition.conditions?.expression || '',
|
||||
|
@ -259,6 +259,7 @@ export function convertReactFlowToWorkflow(nodes: Node[], edges: Edge[]): Condit
|
|||
order: 0,
|
||||
enabled: true,
|
||||
hasIssues: nodeData.hasIssues || false,
|
||||
position: node.position,
|
||||
children: []
|
||||
};
|
||||
|
||||
|
@ -288,6 +289,7 @@ export function convertReactFlowToWorkflow(nodes: Node[], edges: Edge[]): Condit
|
|||
order: 0,
|
||||
enabled: true,
|
||||
hasIssues: false,
|
||||
position: node.position,
|
||||
children: []
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,328 @@
|
|||
import { Node, Edge } from '@xyflow/react';
|
||||
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
|
||||
|
||||
// Validation types
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// Node validation
|
||||
export function validateNode(node: Node): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if node has required data
|
||||
if (!node.data) {
|
||||
errors.push('Node is missing data');
|
||||
}
|
||||
|
||||
// Step node validation
|
||||
if (node.type === 'step') {
|
||||
if (!node.data.name || node.data.name === 'New Step' || node.data.name === 'Unnamed Step') {
|
||||
errors.push('Step must have a meaningful name');
|
||||
}
|
||||
|
||||
if (!node.data.description) {
|
||||
warnings.push('Step should have a description');
|
||||
}
|
||||
}
|
||||
|
||||
// Condition node validation
|
||||
if (node.type === 'condition') {
|
||||
const conditionType = node.data.conditionType;
|
||||
|
||||
if (!conditionType) {
|
||||
errors.push('Condition must have a type');
|
||||
}
|
||||
|
||||
if ((conditionType === 'if' || conditionType === 'elseif') && !node.data.expression) {
|
||||
errors.push('Condition must have an expression');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
// Workflow validation
|
||||
export function validateWorkflow(nodes: Node[], edges: Edge[]): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if workflow is empty
|
||||
if (nodes.length === 0) {
|
||||
warnings.push('Workflow is empty');
|
||||
return { isValid: true, errors, warnings };
|
||||
}
|
||||
|
||||
// Check for disconnected nodes
|
||||
const connectedNodeIds = new Set<string>();
|
||||
edges.forEach(edge => {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
});
|
||||
|
||||
const disconnectedNodes = nodes.filter(node =>
|
||||
!connectedNodeIds.has(node.id) && nodes.length > 1
|
||||
);
|
||||
|
||||
if (disconnectedNodes.length > 0) {
|
||||
warnings.push(`${disconnectedNodes.length} node(s) are not connected`);
|
||||
}
|
||||
|
||||
// Check for circular dependencies
|
||||
if (hasCircularDependency(nodes, edges)) {
|
||||
errors.push('Workflow contains circular dependencies');
|
||||
}
|
||||
|
||||
// Validate individual nodes
|
||||
nodes.forEach(node => {
|
||||
const nodeValidation = validateNode(node);
|
||||
errors.push(...nodeValidation.errors);
|
||||
warnings.push(...nodeValidation.warnings);
|
||||
});
|
||||
|
||||
// Check condition groups
|
||||
const conditionGroups = findConditionGroups(nodes, edges);
|
||||
conditionGroups.forEach(group => {
|
||||
const validation = validateConditionGroup(group);
|
||||
errors.push(...validation.errors);
|
||||
warnings.push(...validation.warnings);
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to detect circular dependencies
|
||||
function hasCircularDependency(nodes: Node[], edges: Edge[]): boolean {
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
|
||||
// Build adjacency list
|
||||
nodes.forEach(node => adjacencyList.set(node.id, []));
|
||||
edges.forEach(edge => {
|
||||
const neighbors = adjacencyList.get(edge.source) || [];
|
||||
neighbors.push(edge.target);
|
||||
adjacencyList.set(edge.source, neighbors);
|
||||
});
|
||||
|
||||
// DFS to detect cycles
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
function hasCycle(nodeId: string): boolean {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
|
||||
const neighbors = adjacencyList.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
if (hasCycle(neighbor)) return true;
|
||||
} else if (recursionStack.has(neighbor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!visited.has(node.id)) {
|
||||
if (hasCycle(node.id)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find condition groups (if/elseif/else chains)
|
||||
interface ConditionGroup {
|
||||
nodes: Node[];
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
function findConditionGroups(nodes: Node[], edges: Edge[]): ConditionGroup[] {
|
||||
const groups: ConditionGroup[] = [];
|
||||
const processedNodes = new Set<string>();
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (node.type === 'condition' && !processedNodes.has(node.id)) {
|
||||
const group: ConditionGroup = {
|
||||
nodes: [],
|
||||
parentId: null
|
||||
};
|
||||
|
||||
// Find parent node
|
||||
const parentEdge = edges.find(edge => edge.target === node.id);
|
||||
if (parentEdge) {
|
||||
group.parentId = parentEdge.source;
|
||||
|
||||
// Find all conditions with same parent
|
||||
const siblingConditions = nodes.filter(n => {
|
||||
if (n.type !== 'condition') return false;
|
||||
const edge = edges.find(e => e.target === n.id);
|
||||
return edge && edge.source === parentEdge.source;
|
||||
});
|
||||
|
||||
group.nodes = siblingConditions;
|
||||
siblingConditions.forEach(n => processedNodes.add(n.id));
|
||||
}
|
||||
|
||||
if (group.nodes.length > 0) {
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Validate condition group
|
||||
function validateConditionGroup(group: ConditionGroup): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for valid condition types
|
||||
const types = group.nodes.map(n => n.data.conditionType);
|
||||
const ifCount = types.filter(t => t === 'if').length;
|
||||
const elseCount = types.filter(t => t === 'else').length;
|
||||
|
||||
if (ifCount === 0) {
|
||||
errors.push('Condition group must start with an "if" condition');
|
||||
}
|
||||
|
||||
if (ifCount > 1) {
|
||||
errors.push('Condition group can only have one "if" condition');
|
||||
}
|
||||
|
||||
if (elseCount > 1) {
|
||||
errors.push('Condition group can only have one "else" condition');
|
||||
}
|
||||
|
||||
// Check order: if -> elseif* -> else?
|
||||
let foundElse = false;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
const type = types[i];
|
||||
|
||||
if (i === 0 && type !== 'if') {
|
||||
errors.push('Condition group must start with "if"');
|
||||
}
|
||||
|
||||
if (type === 'else') {
|
||||
foundElse = true;
|
||||
} else if (foundElse) {
|
||||
errors.push('"else" must be the last condition in a group');
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
// Check if a node can be safely deleted
|
||||
export function canDeleteNode(nodeId: string, nodes: Node[], edges: Edge[]): {
|
||||
canDelete: boolean;
|
||||
reason?: string;
|
||||
} {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) {
|
||||
return { canDelete: false, reason: 'Node not found' };
|
||||
}
|
||||
|
||||
// Don't delete if it's the only node
|
||||
if (nodes.length === 1) {
|
||||
return { canDelete: false, reason: 'Cannot delete the last node' };
|
||||
}
|
||||
|
||||
// Check if deleting this node would create orphaned nodes
|
||||
const outgoingEdges = edges.filter(e => e.source === nodeId);
|
||||
const incomingEdges = edges.filter(e => e.target === nodeId);
|
||||
|
||||
// If node has children that would become disconnected
|
||||
if (outgoingEdges.length > 0 && incomingEdges.length === 0) {
|
||||
const childNodes = outgoingEdges.map(e => e.target);
|
||||
const wouldOrphan = childNodes.some(childId => {
|
||||
const otherIncoming = edges.filter(e =>
|
||||
e.target === childId && e.source !== nodeId
|
||||
);
|
||||
return otherIncoming.length === 0;
|
||||
});
|
||||
|
||||
if (wouldOrphan) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: 'Deleting this node would create disconnected nodes'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { canDelete: true };
|
||||
}
|
||||
|
||||
// Position validation
|
||||
export function validateNodePosition(position: { x: number; y: number }): boolean {
|
||||
// Ensure positions are within reasonable bounds
|
||||
const MAX_POSITION = 10000;
|
||||
const MIN_POSITION = -10000;
|
||||
|
||||
return (
|
||||
position.x >= MIN_POSITION &&
|
||||
position.x <= MAX_POSITION &&
|
||||
position.y >= MIN_POSITION &&
|
||||
position.y <= MAX_POSITION &&
|
||||
!isNaN(position.x) &&
|
||||
!isNaN(position.y)
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-fix common issues
|
||||
export function autoFixWorkflow(nodes: Node[], edges: Edge[]): {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
fixes: string[];
|
||||
} {
|
||||
const fixes: string[] = [];
|
||||
let updatedNodes = [...nodes];
|
||||
let updatedEdges = [...edges];
|
||||
|
||||
// Fix invalid positions
|
||||
updatedNodes = updatedNodes.map(node => {
|
||||
if (!validateNodePosition(node.position)) {
|
||||
fixes.push(`Fixed invalid position for node "${node.data.name || node.id}"`);
|
||||
return {
|
||||
...node,
|
||||
position: { x: 0, y: 0 }
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// Remove duplicate edges
|
||||
const edgeSet = new Set<string>();
|
||||
const uniqueEdges: Edge[] = [];
|
||||
|
||||
updatedEdges.forEach(edge => {
|
||||
const edgeKey = `${edge.source}-${edge.target}`;
|
||||
if (!edgeSet.has(edgeKey)) {
|
||||
edgeSet.add(edgeKey);
|
||||
uniqueEdges.push(edge);
|
||||
} else {
|
||||
fixes.push('Removed duplicate edge');
|
||||
}
|
||||
});
|
||||
updatedEdges = uniqueEdges;
|
||||
|
||||
return {
|
||||
nodes: updatedNodes,
|
||||
edges: updatedEdges,
|
||||
fixes
|
||||
};
|
||||
}
|
|
@ -26,14 +26,16 @@ import edgeTypes from './edge-types';
|
|||
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
|
||||
import { uuid } from './utils';
|
||||
import { convertWorkflowToReactFlow, convertReactFlowToWorkflow } from './utils/conversion';
|
||||
import { validateWorkflow, canDeleteNode, autoFixWorkflow } from './utils/validation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, GitBranch } from 'lucide-react';
|
||||
import { Plus, GitBranch, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const proOptions: ProOptions = { account: 'paid-pro', hideAttribution: true };
|
||||
|
@ -313,10 +315,12 @@ function WorkflowBuilderInner({
|
|||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.5}
|
||||
maxZoom={1.5}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
nodesDraggable={true}
|
||||
nodesConnectable={true}
|
||||
zoomOnDoubleClick={false}
|
||||
deleteKeyCode={null}
|
||||
snapToGrid={true}
|
||||
snapGrid={[15, 15]}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls />
|
||||
|
|
Loading…
Reference in New Issue