feat(subflows): editor, block; fix(copilot): stop, mr (#1877)

This commit is contained in:
Emir Karabeg
2025-11-10 18:38:14 -08:00
committed by GitHub
parent 118c477d97
commit 170eb36ebf
21 changed files with 1167 additions and 2182 deletions

View File

@@ -2,18 +2,9 @@
@tailwind components;
@tailwind utilities;
/* ==========================================================================
SIDEBAR WIDTH PERSISTENCE
========================================================================== */
/**
* CSS-based sidebar width to prevent SSR hydration mismatches.
*
* How it works:
* 1. Default width set here in CSS (232px)
* 2. Blocking script in layout.tsx updates this before React hydrates
* 3. Store updates this variable when user resizes
*
* This approach ensures server and client always render identical HTML.
* CSS-based sidebar and panel widths to prevent SSR hydration mismatches.
* Default widths are set here and updated via blocking script before React hydrates.
*/
:root {
--sidebar-width: 232px;
@@ -35,9 +26,9 @@
height: var(--terminal-height);
}
/* ==========================================================================
WORKFLOW COMPONENT Z-INDEX FIXES
========================================================================== */
/**
* Workflow component z-index fixes and background colors
*/
.workflow-container .react-flow__edges {
z-index: 0 !important;
}
@@ -46,12 +37,6 @@
z-index: 21 !important;
}
.workflow-container .react-flow__node-loopNode,
.workflow-container .react-flow__node-parallelNode,
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
.workflow-container .react-flow__handle {
z-index: 30 !important;
}
@@ -64,7 +49,6 @@
z-index: 60 !important;
}
/* Workflow canvas background */
.workflow-container,
.workflow-container .react-flow__pane,
.workflow-container .react-flow__renderer {
@@ -77,183 +61,110 @@
background-color: #1b1b1b !important;
}
/* ==========================================================================
LANDING LOOP ANIMATION
========================================================================== */
@keyframes dash-animation {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -24;
}
}
/**
* Landing loop animation styles (keyframes defined in tailwind.config.ts)
*/
.landing-loop-animated-dash {
animation: dash-animation 1.5s linear infinite;
/* Ensure animation works in React Flow context */
will-change: stroke-dashoffset;
transform: translateZ(0);
}
/* Ensure React Flow doesn't override our animation */
.react-flow__node-landingLoop svg rect.landing-loop-animated-dash {
animation: dash-animation 1.5s linear infinite !important;
}
/* ==========================================================================
THEME SYSTEM - CSS CUSTOM PROPERTIES
========================================================================== */
/**
* Theme system - CSS custom properties for light and dark modes
*/
@layer base {
/* Light Mode Theme */
:root,
.light {
/* Core Colors */
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
/* Card Colors */
--card: 0 0% 99.2%;
--card-foreground: 0 0% 3.9%;
/* Popover Colors */
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
/* Primary Colors */
--primary: 0 0% 11.2%;
--primary-foreground: 0 0% 98%;
/* Secondary Colors */
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 11.2%;
/* Muted Colors */
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 46.9%;
/* Accent Colors */
--accent: 0 0% 92.5%;
--accent-foreground: 0 0% 11.2%;
/* Destructive Colors */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
/* Border & Input Colors */
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--input-background: 0 0% 79.22%;
--ring: 0 0% 3.9%;
/* Border Radius */
--radius: 0.5rem;
/* Scrollbar Properties */
--scrollbar-track: 0 0% 85%;
--scrollbar-thumb: 0 0% 65%;
--scrollbar-thumb-hover: 0 0% 55%;
--scrollbar-size: 8px;
/* Workflow Properties */
--workflow-background: 0 0% 100%;
--card-background: 0 0% 99.2%;
--card-border: 0 0% 89.8%;
--card-text: 0 0% 3.9%;
--card-hover: 0 0% 96.1%;
/* Base Component Properties */
--base-muted-foreground: #737373;
/* Gradient Colors */
--gradient-primary: 263 85% 70%; /* More vibrant purple */
--gradient-secondary: 336 95% 65%; /* More vibrant pink */
/* Brand Colors (Default Sim Theme) */
--brand-primary-hex: #6f3dfa; /* Primary brand purple - matches Get Started gradient start */
--brand-primary-hover-hex: #6338d9; /* Primary brand purple hover - matches Get Started hover */
--brand-accent-hex: #6f3dfa; /* Accent purple for links - matches sign in button */
--brand-accent-hover-hex: #6f3dfa; /* Accent purple hover - matches sign in gradient start */
--brand-background-hex: #ffffff; /* Primary light background */
/* UI Surface Colors */
--surface-elevated: #202020; /* Elevated surface background for dark mode */
--gradient-primary: 263 85% 70%;
--gradient-secondary: 336 95% 65%;
--brand-primary-hex: #6f3dfa;
--brand-primary-hover-hex: #6338d9;
--brand-accent-hex: #6f3dfa;
--brand-accent-hover-hex: #6f3dfa;
--brand-background-hex: #ffffff;
--surface-elevated: #202020;
}
/* Dark Mode Theme */
.dark {
/* Core Colors */
--background: 0 0% 10.59%;
--foreground: 0 0% 98%;
/* Card Colors */
--card: 0 0% 9.0%;
--card-foreground: 0 0% 98%;
/* Popover Colors */
--popover: 0 0% 9.0%;
--popover-foreground: 0 0% 98%;
/* Primary Colors */
--primary: 0 0% 11.2%;
--primary-foreground: 0 0% 98%;
/* Secondary Colors */
--secondary: 0 0% 12.0%;
--secondary-foreground: 0 0% 98%;
/* Muted Colors */
--muted: 0 0% 17.5%;
--muted-foreground: 0 0% 65.1%;
/* Accent Colors */
--accent: 0 0% 17.5%;
--accent-foreground: 0 0% 98%;
/* Destructive Colors */
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
/* Border & Input Colors */
--border: 0 0% 16.1%;
--input: 0 0% 16.1%;
--input-background: 0 0% 20.78%;
--ring: 0 0% 83.9%;
/* Scrollbar Properties */
--scrollbar-track: 0 0% 17.5%;
--scrollbar-thumb: 0 0% 30%;
--scrollbar-thumb-hover: 0 0% 40%;
/* Workflow Properties */
--workflow-background: 0 0% 10.59%;
--card-background: 0 0% 9.0%;
--card-border: 0 0% 22.7%;
--card-text: 0 0% 98%;
--card-hover: 0 0% 12.0%;
/* Base Component Properties */
--base-muted-foreground: #a3a3a3;
/* Gradient Colors - Adjusted for dark mode */
--gradient-primary: 263 90% 75%; /* More vibrant purple for dark mode */
--gradient-secondary: 336 100% 72%; /* More vibrant pink for dark mode */
/* Brand Colors (Keep dark background for actual dark mode) */
--brand-primary-hex: #701ffc; /* Primary brand purple */
--brand-primary-hover-hex: #802fff; /* Primary brand purple hover */
--brand-accent-hex: #9d54ff; /* Accent purple for links */
--brand-accent-hover-hex: #a66fff; /* Accent purple hover */
--brand-background-hex: #0c0c0c; /* Primary dark background */
/* UI Surface Colors */
--surface-elevated: #202020; /* Elevated surface background for dark mode */
--gradient-primary: 263 90% 75%;
--gradient-secondary: 336 100% 72%;
--brand-primary-hex: #701ffc;
--brand-primary-hover-hex: #802fff;
--brand-accent-hex: #9d54ff;
--brand-accent-hover-hex: #a66fff;
--brand-background-hex: #0c0c0c;
--surface-elevated: #202020;
}
}
/* ==========================================================================
BASE STYLES
========================================================================== */
/**
* Base styles for body, scrollbars, and global elements
*/
@layer base {
* {
@apply border-border;
@@ -269,19 +180,14 @@
overscroll-behavior-x: none;
overscroll-behavior-y: none;
min-height: 100vh;
/* Prevent layout shifts when scrollbar appears/disappears */
scrollbar-gutter: stable;
/* Improve animation performance */
text-rendering: optimizeSpeed;
/* Default text styles */
letter-spacing: 0.28px;
}
.dark body {
@apply antialiased;
}
/* Global Scrollbar Styling */
::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
@@ -313,7 +219,6 @@
background-color: hsl(var(--muted-foreground) / 0.4);
}
/* Firefox Scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) #f5f5f5;
@@ -323,27 +228,24 @@
scrollbar-color: hsl(var(--muted-foreground) / 0.3) #272727;
}
/* Copilot Scrollbar - Ensure stable gutter */
.copilot-scrollable {
scrollbar-gutter: stable;
}
}
/* ==========================================================================
PANEL STYLES
========================================================================== */
/**
* Panel tab styles
*/
.panel-tab-base {
color: var(--base-muted-foreground);
}
.panel-tab-active {
/* Light Mode Panel Tab Active */
background-color: #f5f5f5;
color: #1a1a1a;
border-color: #e5e5e5;
}
/* Dark Mode Panel Tab Active */
.dark .panel-tab-active {
background-color: #1f1f1f;
color: #ffffff;
@@ -355,58 +257,51 @@
color: hsl(var(--card-foreground));
}
/* ==========================================================================
DARK MODE OVERRIDES
========================================================================== */
/**
* Dark mode specific overrides
*/
.dark .error-badge {
background-color: hsl(0, 70%, 20%) !important;
/* Darker red background for dark mode */
color: hsl(0, 0%, 100%) !important;
/* Pure white text for better contrast */
}
.dark .bg-red-500 {
@apply bg-red-700;
}
/* ==========================================================================
BROWSER INPUT OVERRIDES
========================================================================== */
/* Chrome, Safari, Edge, Opera */
/**
* Browser input overrides
*/
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
/* Firefox */
input[type="search"]::-moz-search-cancel-button {
display: none;
}
/* Microsoft Edge */
input[type="search"]::-ms-clear {
display: none;
}
/* ==========================================================================
LAYOUT UTILITIES
========================================================================== */
/**
* Layout utilities
*/
.main-content-overlay {
z-index: 40;
/* Higher z-index to appear above content */
}
/* ==========================================================================
ANIMATIONS & UTILITIES
========================================================================== */
/**
* Utilities and special effects
* Animation keyframes are defined in tailwind.config.ts
*/
@layer utilities {
/* Animation Performance */
.animation-container {
contain: paint layout style;
will-change: opacity, transform;
}
/* Scrollbar Utilities */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
@@ -417,17 +312,12 @@ input[type="search"]::-ms-clear {
}
.scrollbar-hide {
/* For Webkit browsers (Chrome, Safari, Edge) */
-webkit-scrollbar: none;
-webkit-scrollbar-width: none;
-webkit-scrollbar-track: transparent;
-webkit-scrollbar-thumb: transparent;
/* For Firefox */
scrollbar-width: none;
scrollbar-color: transparent transparent;
/* For Internet Explorer and Edge Legacy */
-ms-overflow-style: none;
}
@@ -448,19 +338,16 @@ input[type="search"]::-ms-clear {
background: transparent;
}
/* Gradient Text Utility - Use with Tailwind gradient directions */
.gradient-text {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Input Background Utility */
.bg-input-background {
background-color: hsl(var(--input-background));
}
/* Brand Color Utilities */
.bg-brand-primary {
background-color: var(--brand-primary-hex);
}
@@ -477,7 +364,6 @@ input[type="search"]::-ms-clear {
color: var(--brand-accent-hover-hex);
}
/* Gradient Button Utilities */
.bg-brand-gradient {
background: linear-gradient(
to bottom,
@@ -502,7 +388,6 @@ input[type="search"]::-ms-clear {
);
}
/* Apply fixed default light theme values for auth components */
.auth-card {
background-color: rgba(255, 255, 255, 0.9) !important;
border-color: #e5e5e5 !important;
@@ -534,39 +419,16 @@ input[type="search"]::-ms-clear {
color: #737373 !important;
}
/* Surface Utilities */
.bg-surface-elevated {
background-color: var(--surface-elevated);
}
/* Animation Classes */
.animate-pulse-ring {
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.transition-ring {
transition-property: box-shadow, transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.animate-orbit {
animation: orbit calc(var(--duration, 2) * 1s) linear infinite;
}
.animate-marquee {
animation: marquee var(--duration) infinite linear;
}
.animate-marquee-vertical {
animation: marquee-vertical var(--duration) linear infinite;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out forwards;
}
/* Special Effects */
.streaming-effect {
@apply relative overflow-hidden;
}
@@ -597,7 +459,6 @@ input[type="search"]::-ms-clear {
animation: placeholder-pulse 1.5s ease-in-out infinite;
}
/* Auth Button Styles - Default Gradient */
.auth-button-gradient {
background: linear-gradient(to bottom, #8357ff, #6f3dfa) !important;
border-color: #6f3dfa !important;
@@ -609,11 +470,9 @@ input[type="search"]::-ms-clear {
opacity: 0.9;
}
/* Auth Button Styles - CSS Variable Based */
.auth-button-custom {
background: var(--brand-accent-hex) !important;
border-color: var(--brand-accent-hex) !important;
/* Remove purple shadow when using custom color */
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1) !important;
}
@@ -623,90 +482,8 @@ input[type="search"]::-ms-clear {
opacity: 1;
}
/* ==========================================================================
KEYFRAME ANIMATIONS
========================================================================== */
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 hsl(var(--border));
}
50% {
box-shadow: 0 0 0 8px hsl(var(--border));
}
100% {
box-shadow: 0 0 0 0 hsl(var(--border));
}
}
@keyframes code-shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes orbit {
0% {
transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px))
rotate(calc(var(--angle) * -1deg));
}
100% {
transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px))
rotate(calc((var(--angle) * -1deg) - 360deg));
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes placeholder-pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 0.8;
}
}
/**
* Panel tab visibility - prevent flash on hydration
* Before React hydrates, use data attribute set by blocking script
* to show the correct tab content and button states
* Panel tab visibility and styling to prevent hydration flash
*/
html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="toolbar"],
html[data-panel-active-tab="copilot"] .panel-container [data-tab-content="editor"] {
@@ -723,13 +500,6 @@ input[type="search"]::-ms-clear {
display: none !important;
}
/**
* Style active and inactive tab buttons before hydration
* Ensure only the correct tab shows as active
* Note: Colors match the ghost button variant for consistency
*/
/* Make only copilot active when it's the active tab */
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="copilot"] {
background-color: rgb(53 53 53) !important;
color: rgb(230 230 230) !important;
@@ -737,10 +507,9 @@ input[type="search"]::-ms-clear {
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="toolbar"],
html[data-panel-active-tab="copilot"] .panel-container [data-tab-button="editor"] {
background-color: transparent !important;
color: rgb(174 174 174) !important; /* Muted gray for inactive tabs */
color: rgb(174 174 174) !important;
}
/* Make only toolbar active when it's the active tab */
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="toolbar"] {
background-color: rgb(53 53 53) !important;
color: rgb(230 230 230) !important;
@@ -748,10 +517,9 @@ input[type="search"]::-ms-clear {
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="copilot"],
html[data-panel-active-tab="toolbar"] .panel-container [data-tab-button="editor"] {
background-color: transparent !important;
color: rgb(174 174 174) !important; /* Muted gray for inactive tabs */
color: rgb(174 174 174) !important;
}
/* Make only editor active when it's the active tab */
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="editor"] {
background-color: rgb(53 53 53) !important;
color: rgb(230 230 230) !important;
@@ -759,6 +527,6 @@ input[type="search"]::-ms-clear {
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="copilot"],
html[data-panel-active-tab="editor"] .panel-container [data-tab-button="toolbar"] {
background-color: transparent !important;
color: rgb(174 174 174) !important; /* Muted gray for inactive tabs */
color: rgb(174 174 174) !important;
}
}

View File

@@ -317,7 +317,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
>
<div
ref={messageContentRef}
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[#0D0D0D] text-sm leading-[1.25rem] dark:text-gray-100 ${isSendingMessage && isLastUserMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[#0D0D0D] text-sm leading-[1.25rem] dark:text-gray-100 ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
>
{(() => {
const text = message.content || ''
@@ -374,7 +374,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
title='Stop generation'
>
<svg
className='h-[13px] w-[13px]'
className='block h-[13px] w-[13px]'
viewBox='0 0 24 24'
fill='black'
xmlns='http://www.w3.org/2000/svg'

View File

@@ -695,10 +695,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Stop generation'
>
{isAborting ? (
<Loader2 className='h-[13px] w-[13px] animate-spin text-black' />
<Loader2 className='block h-[13px] w-[13px] animate-spin text-black' />
) : (
<svg
className='h-[13px] w-[13px]'
className='block h-[13px] w-[13px]'
viewBox='0 0 24 24'
fill='black'
xmlns='http://www.w3.org/2000/svg'
@@ -719,9 +719,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
)}
>
{isLoading ? (
<Loader2 className='h-3.5 w-3.5 animate-spin text-black' />
<Loader2 className='block h-3.5 w-3.5 animate-spin text-black' />
) : (
<ArrowUp className='h-3.5 w-3.5 text-black' strokeWidth={2.25} />
<ArrowUp className='block h-3.5 w-3.5 text-black' strokeWidth={2.25} />
)}
</Button>
)}

View File

@@ -1,2 +1,3 @@
export { ConnectionBlocks } from './connection-blocks/connection-blocks'
export { SubBlock } from './sub-block/sub-block'
export { SubflowEditor } from './subflow-editor/subflow-editor'

View File

@@ -0,0 +1,214 @@
'use client'
import { ChevronUp } from 'lucide-react'
import SimpleCodeEditor from 'react-simple-code-editor'
import { Code as CodeEditor, Combobox, getCodeEditorProps, Input, Label } from '@/components/emcn'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ConnectedBlock } from '../../hooks/use-block-connections'
import { useSubflowEditor } from '../../hooks/use-subflow-editor'
import { ConnectionBlocks } from '../connection-blocks'
interface SubflowEditorProps {
currentBlock: BlockState
currentBlockId: string
subBlocksRef: React.RefObject<HTMLDivElement | null>
connectionsHeight: number
isResizing: boolean
hasIncomingConnections: boolean
incomingConnections: ConnectedBlock[]
handleConnectionsResizeMouseDown: (e: React.MouseEvent) => void
toggleConnectionsCollapsed: () => void
userCanEdit: boolean
isConnectionsAtMinHeight: boolean
}
/**
* SubflowEditor component for editing loop and parallel blocks
*
* @param props - Component props
* @returns Rendered subflow editor
*/
export function SubflowEditor({
currentBlock,
currentBlockId,
subBlocksRef,
connectionsHeight,
isResizing,
hasIncomingConnections,
incomingConnections,
handleConnectionsResizeMouseDown,
toggleConnectionsCollapsed,
userCanEdit,
isConnectionsAtMinHeight,
}: SubflowEditorProps) {
const {
subflowConfig,
currentType,
isCountMode,
isConditionMode,
inputValue,
editorValue,
typeOptions,
showTagDropdown,
cursorPosition,
editorContainerRef,
handleSubflowTypeChange,
handleSubflowIterationsChange,
handleSubflowIterationsSave,
handleSubflowEditorChange,
handleSubflowTagSelect,
highlightWithReferences,
setShowTagDropdown,
} = useSubflowEditor(currentBlock, currentBlockId)
if (!subflowConfig) return null
return (
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subflow Editor Section */}
<div ref={subBlocksRef} className='subblocks-section flex flex-1 flex-col overflow-hidden'>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[5px] pb-[8px]'>
{/* Type Selection */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
{currentBlock.type === 'loop' ? 'Loop Type' : 'Parallel Type'}
</Label>
<Combobox
options={typeOptions}
value={currentType || ''}
onChange={handleSubflowTypeChange}
disabled={!userCanEdit}
placeholder='Select type...'
/>
</div>
{/* Dashed Line Separator */}
<div className='px-[2px] pt-[16px] pb-[10px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, #2C2C2C 0px, #2C2C2C 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
{/* Configuration */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
{isCountMode
? `${currentBlock.type === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: isConditionMode
? 'While Condition'
: `${currentBlock.type === 'loop' ? 'Collection' : 'Parallel'} Items`}
</Label>
{isCountMode ? (
<div>
<Input
type='text'
value={inputValue}
onChange={handleSubflowIterationsChange}
onBlur={handleSubflowIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleSubflowIterationsSave()}
disabled={!userCanEdit}
className='mb-[4px]'
/>
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and {subflowConfig.maxIterations}
</div>
</div>
) : (
<div ref={editorContainerRef} className='relative'>
<CodeEditor.Container>
<CodeEditor.Content>
<CodeEditor.Placeholder gutterWidth={0} show={editorValue.length === 0}>
{isConditionMode ? '<counter.value> < 10' : "['item1', 'item2', 'item3']"}
</CodeEditor.Placeholder>
<SimpleCodeEditor
value={editorValue}
onValueChange={handleSubflowEditorChange}
highlight={highlightWithReferences}
{...getCodeEditorProps({
isPreview: false,
disabled: !userCanEdit,
})}
/>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleSubflowTagSelect}
blockId={currentBlockId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
inputRef={{
current: editorContainerRef.current?.querySelector(
'textarea'
) as HTMLTextAreaElement,
}}
/>
)}
</CodeEditor.Content>
</CodeEditor.Container>
</div>
)}
</div>
</div>
</div>
{/* Connections Section - Only show when there are connections */}
{hasIncomingConnections && (
<div
className={
'connections-section flex flex-shrink-0 flex-col overflow-hidden border-[#2C2C2C] border-t dark:border-[#2C2C2C]' +
(!isResizing ? ' transition-[height] duration-100 ease-out' : '')
}
style={{ height: `${connectionsHeight}px` }}
>
{/* Resize Handle */}
<div className='relative'>
<div
className='absolute top-[-4px] right-0 left-0 z-30 h-[8px] cursor-ns-resize'
onMouseDown={handleConnectionsResizeMouseDown}
/>
</div>
{/* Connections Header with Chevron */}
<div
className='flex flex-shrink-0 cursor-pointer items-center gap-[8px] px-[10px] pt-[5px] pb-[5px]'
onClick={toggleConnectionsCollapsed}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleConnectionsCollapsed()
}
}}
role='button'
tabIndex={0}
aria-label={isConnectionsAtMinHeight ? 'Expand connections' : 'Collapse connections'}
>
<ChevronUp
className={
'h-[14px] w-[14px] transition-transform' +
(!isConnectionsAtMinHeight ? ' rotate-180' : '')
}
/>
<div className='font-medium text-[#E6E6E6] text-[13px] dark:text-[#E6E6E6]'>
Connections
</div>
</div>
{/* Connections Content - Always visible */}
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[6px] pb-[8px]'>
<ConnectionBlocks connections={incomingConnections} currentBlockId={currentBlock.id} />
</div>
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useRef } from 'react'
import { BookOpen, ChevronUp, Crosshair, Settings } from 'lucide-react'
import { BookOpen, ChevronUp, Crosshair, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
@@ -12,7 +12,7 @@ import { useFocusOnBlock } from '@/hooks/use-focus-on-block'
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { ConnectionBlocks, SubBlock } from './components'
import { ConnectionBlocks, SubBlock, SubflowEditor } from './components'
import {
useBlockConnections,
useConnectionsResize,
@@ -45,6 +45,14 @@ export function Editor() {
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const title = currentBlock?.name || 'Editor'
// Check if selected block is a subflow (loop or parallel)
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties
const subflowIcon = isSubflow && currentBlock.type === 'loop' ? RepeatIcon : SplitIcon
const subflowBgColor = isSubflow && currentBlock.type === 'loop' ? '#2FB3FF' : '#FEE12B'
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -126,12 +134,15 @@ export function Editor() {
{/* Header */}
<div className='flex flex-shrink-0 items-center justify-between rounded-[4px] bg-[#2A2A2A] px-[12px] py-[8px] dark:bg-[#2A2A2A]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{blockConfig && (
{(blockConfig || isSubflow) && (
<div
className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px]'
style={{ backgroundColor: blockConfig.bgColor }}
style={{ backgroundColor: isSubflow ? subflowBgColor : blockConfig?.bgColor }}
>
<IconComponent icon={blockConfig.icon} className='h-[12px] w-[12px] text-[#FFFFFF]' />
<IconComponent
icon={isSubflow ? subflowIcon : blockConfig?.icon}
className='h-[12px] w-[12px] text-[#FFFFFF]'
/>
</div>
)}
<h2
@@ -160,8 +171,8 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Mode toggles */}
{currentBlock && hasAdvancedMode && (
{/* Mode toggles - Only show for regular blocks, not subflows */}
{currentBlock && !isSubflow && hasAdvancedMode && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -179,7 +190,7 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)}
{currentBlock && blockConfig?.docsLink && (
{currentBlock && !isSubflow && blockConfig?.docsLink && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -203,6 +214,20 @@ export function Editor() {
<div className='flex flex-1 items-center justify-center text-[#8D8D8D] text-[13px]'>
Select a block to edit
</div>
) : isSubflow ? (
<SubflowEditor
currentBlock={currentBlock}
currentBlockId={currentBlockId}
subBlocksRef={subBlocksRef}
connectionsHeight={connectionsHeight}
isResizing={isResizing}
hasIncomingConnections={hasIncomingConnections}
incomingConnections={incomingConnections}
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
userCanEdit={userPermissions.canEdit}
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
/>
) : (
<div className='flex flex-1 flex-col overflow-hidden pt-[0px]'>
{/* Subblocks Section */}

View File

@@ -2,3 +2,4 @@ export { useBlockConnections } from './use-block-connections'
export { useConnectionsResize } from './use-connections-resize'
export { useEditorBlockProperties } from './use-editor-block-properties'
export { useEditorSubblockLayout } from './use-editor-subblock-layout'
export { useSubflowEditor } from './use-subflow-editor'

View File

@@ -0,0 +1,366 @@
import { useCallback, useRef, useState } from 'react'
import { highlight, languages } from '@/components/emcn'
import {
isLikelyReferenceSegment,
SYSTEM_REFERENCE_PREFIXES,
splitReferenceSegment,
} from '@/lib/workflows/references'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { normalizeBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Configuration for subflow types (loop and parallel)
*/
const SUBFLOW_CONFIG = {
loop: {
typeLabels: {
for: 'For Loop',
forEach: 'For Each',
while: 'While Loop',
doWhile: 'Do While Loop',
},
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,
condition: 'whileCondition' as const,
} as any,
},
parallel: {
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
typeKey: 'parallelType' as const,
storeKey: 'parallels' as const,
maxIterations: 20,
configKeys: {
iterations: 'count' as const,
items: 'distribution' as const,
},
},
} as const
/**
* Hook for managing subflow editor state and logic
*
* @param currentBlock - The current block being edited
* @param currentBlockId - The ID of the current block
* @returns Subflow editor state and handlers
*/
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
const workflowStore = useWorkflowStore()
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement>(null)
// State
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const [showTagDropdown, setShowTagDropdown] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
// Check if current block is a subflow
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow configuration
const subflowConfig = isSubflow ? SUBFLOW_CONFIG[currentBlock.type as 'loop' | 'parallel'] : null
const nodeConfig = isSubflow ? workflowStore[subflowConfig!.storeKey][currentBlockId!] : null
// Get block data for fallback values
const blockData = isSubflow ? currentBlock?.data : null
// Get accessible prefixes for tag dropdown
const accessiblePrefixes = useAccessibleReferencePrefixes(currentBlockId || '')
// Collaborative actions
const {
collaborativeUpdateLoopType,
collaborativeUpdateParallelType,
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
} = useCollaborativeWorkflow()
/**
* Checks if a reference should be highlighted based on accessible prefixes
*/
const shouldHighlightReference = useCallback(
(part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const reference = split.reference
if (!accessiblePrefixes) {
return true
}
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
return accessiblePrefixes.has(normalizedPrefix)
},
[accessiblePrefixes]
)
/**
* Highlights code with references and environment variables
*/
const highlightWithReferences = useCallback(
(code: string): string => {
const placeholders: Array<{
placeholder: string
original: string
type: 'var' | 'env'
}> = []
let processedCode = code
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
processedCode = processedCode.replace(/<[^>]+>/g, (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'var' })
return placeholder
}
return match
})
let highlightedCode = highlight(processedCode, languages.javascript, 'javascript')
placeholders.forEach(({ placeholder, original, type }) => {
if (type === 'env') {
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${original}</span>`
)
} else {
const escaped = original.replace(/</g, '&lt;').replace(/>/g, '&gt;')
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${escaped}</span>`
)
}
})
return highlightedCode
},
[shouldHighlightReference]
)
/**
* Handle subflow type change (loop type or parallel type)
*/
const handleSubflowTypeChange = useCallback(
(newType: string) => {
if (!currentBlockId || !isSubflow || !currentBlock) return
if (currentBlock.type === 'loop') {
collaborativeUpdateLoopType(
currentBlockId,
newType as 'for' | 'forEach' | 'while' | 'doWhile'
)
} else {
collaborativeUpdateParallelType(currentBlockId, newType as 'count' | 'collection')
}
},
[
currentBlockId,
isSubflow,
currentBlock,
collaborativeUpdateLoopType,
collaborativeUpdateParallelType,
]
)
/**
* Handle iterations input change
*/
const handleSubflowIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!subflowConfig) return
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setTempInputValue(Math.min(subflowConfig.maxIterations, numValue).toString())
} else {
setTempInputValue(sanitizedValue)
}
},
[subflowConfig]
)
/**
* Save iterations value
*/
const handleSubflowIterationsSave = useCallback(() => {
if (!currentBlockId || !isSubflow || !subflowConfig || !currentBlock) return
const value = Number.parseInt(tempInputValue ?? '5')
if (!Number.isNaN(value)) {
const newValue = Math.min(subflowConfig.maxIterations, Math.max(1, value))
collaborativeUpdateIterationCount(
currentBlockId,
currentBlock.type as 'loop' | 'parallel',
newValue
)
}
setTempInputValue(null)
}, [
tempInputValue,
currentBlockId,
isSubflow,
subflowConfig,
currentBlock,
collaborativeUpdateIterationCount,
])
/**
* Handle editor value change (collection/condition)
*/
const handleSubflowEditorChange = useCallback(
(value: string) => {
if (!currentBlockId || !isSubflow || !currentBlock) return
collaborativeUpdateIterationCollection(
currentBlockId,
currentBlock.type as 'loop' | 'parallel',
value
)
const textarea = editorContainerRef.current?.querySelector('textarea')
if (textarea) {
textareaRef.current = textarea
const cursorPos = textarea.selectionStart || 0
setCursorPosition(cursorPos)
const triggerCheck = checkTagTrigger(value, cursorPos)
setShowTagDropdown(triggerCheck.show)
}
},
[currentBlockId, isSubflow, currentBlock, collaborativeUpdateIterationCollection]
)
/**
* Handle tag selection from dropdown
*/
const handleSubflowTagSelect = useCallback(
(newValue: string) => {
if (!currentBlockId || !isSubflow || !currentBlock) return
collaborativeUpdateIterationCollection(
currentBlockId,
currentBlock.type as 'loop' | 'parallel',
newValue
)
setShowTagDropdown(false)
setTimeout(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
}
}, 0)
},
[currentBlockId, isSubflow, currentBlock, collaborativeUpdateIterationCollection]
)
// Compute derived values
const currentType =
isSubflow && subflowConfig
? (nodeConfig as any)?.[subflowConfig.typeKey] ||
(blockData as any)?.[subflowConfig.typeKey] ||
(currentBlock!.type === 'loop' ? 'for' : 'count')
: null
const isCountMode = currentType === 'for' || currentType === 'count'
const isConditionMode = currentType === 'while' || currentType === 'doWhile'
const configIterations =
isSubflow && subflowConfig
? ((nodeConfig as any)?.[subflowConfig.configKeys.iterations] ??
(blockData as any)?.count ??
5)
: 5
const configCollection =
isSubflow && subflowConfig
? ((nodeConfig as any)?.[subflowConfig.configKeys.items] ??
(blockData as any)?.collection ??
'')
: ''
const conditionKey =
currentType === 'while'
? 'whileCondition'
: currentType === 'doWhile'
? 'doWhileCondition'
: null
const configCondition =
isSubflow && conditionKey
? ((nodeConfig as any)?.[conditionKey] ?? (blockData as any)?.[conditionKey] ?? '')
: ''
const iterations = configIterations
const collectionString =
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
const conditionString = typeof configCondition === 'string' ? configCondition : ''
const inputValue = tempInputValue ?? iterations.toString()
const editorValue = isConditionMode ? conditionString : collectionString
// Type options for combobox
const typeOptions =
isSubflow && subflowConfig
? Object.entries(subflowConfig.typeLabels).map(([value, label]) => ({
value,
label,
}))
: []
return {
// State
isSubflow,
subflowConfig,
currentType,
isCountMode,
isConditionMode,
inputValue,
editorValue,
typeOptions,
showTagDropdown,
cursorPosition,
textareaRef,
editorContainerRef,
// Handlers
handleSubflowTypeChange,
handleSubflowIterationsChange,
handleSubflowIterationsSave,
handleSubflowEditorChange,
handleSubflowTagSelect,
highlightWithReferences,
setShowTagDropdown,
}
}

View File

@@ -403,6 +403,10 @@ export function Toolbar({ isActive = true }: ToolbarProps) {
key={block.type}
draggable
onDragStart={(e) => {
// Mark subflow drag explicitly so canvas can reliably detect and suppress highlight
if (block.type === 'loop' || block.type === 'parallel') {
document.body.classList.add('sim-drag-subflow')
}
const iconElement = e.currentTarget.querySelector('.toolbar-item-icon')
handleDragStart(e, block.type, false, {
name: block.name,
@@ -410,6 +414,10 @@ export function Toolbar({ isActive = true }: ToolbarProps) {
iconElement: iconElement as HTMLElement | null,
})
}}
onDragEnd={() => {
// Always clear the flag at the end of a toolbar drag
document.body.classList.remove('sim-drag-subflow')
}}
onClick={() => handleItemClick(block.type, false)}
className={clsx(
'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]',

View File

@@ -1,386 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LoopType, ParallelType } from '@/lib/workflows/types'
// Mock hooks
const mockCollaborativeUpdates = {
collaborativeUpdateLoopType: vi.fn(),
collaborativeUpdateParallelType: vi.fn(),
collaborativeUpdateIterationCount: vi.fn(),
collaborativeUpdateIterationCollection: vi.fn(),
}
const mockStoreData = {
loops: {},
parallels: {},
}
vi.mock('@/hooks/use-collaborative-workflow', () => ({
useCollaborativeWorkflow: () => mockCollaborativeUpdates,
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: () => mockStoreData,
}))
vi.mock('@/components/ui/badge', () => ({
Badge: ({ children, ...props }: any) => (
<div data-testid='badge' {...props}>
{children}
</div>
),
}))
vi.mock('@/components/ui/input', () => ({
Input: (props: any) => <input data-testid='input' {...props} />,
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: any) => <div data-testid='popover'>{children}</div>,
PopoverContent: ({ children }: any) => <div data-testid='popover-content'>{children}</div>,
PopoverTrigger: ({ children }: any) => <div data-testid='popover-trigger'>{children}</div>,
}))
vi.mock('@/components/ui/tag-dropdown', () => ({
checkTagTrigger: vi.fn(() => ({ show: false })),
TagDropdown: ({ children }: any) => <div data-testid='tag-dropdown'>{children}</div>,
}))
vi.mock('react-simple-code-editor', () => ({
default: (props: any) => <textarea data-testid='code-editor' {...props} />,
}))
describe('IterationBadges', () => {
const defaultProps = {
nodeId: 'test-node-1',
data: {
width: 500,
height: 300,
isPreview: false,
},
iterationType: 'loop' as const,
}
beforeEach(() => {
vi.clearAllMocks()
mockStoreData.loops = {}
mockStoreData.parallels = {}
})
describe('Component Interface', () => {
it.concurrent('should accept required props', () => {
expect(defaultProps.nodeId).toBeDefined()
expect(defaultProps.data).toBeDefined()
expect(defaultProps.iterationType).toBeDefined()
})
it.concurrent('should handle loop iteration type prop', () => {
const loopProps = { ...defaultProps, iterationType: 'loop' as const }
expect(loopProps.iterationType).toBe('loop')
})
it.concurrent('should handle parallel iteration type prop', () => {
const parallelProps = { ...defaultProps, iterationType: 'parallel' as const }
expect(parallelProps.iterationType).toBe('parallel')
})
})
describe('Configuration System', () => {
it.concurrent('should use correct config for loop type', () => {
const CONFIG = {
loop: {
typeLabels: { for: 'For Loop', forEach: 'For Each' },
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,
},
},
}
expect(CONFIG.loop.typeLabels.for).toBe('For Loop')
expect(CONFIG.loop.typeLabels.forEach).toBe('For Each')
expect(CONFIG.loop.maxIterations).toBe(100)
expect(CONFIG.loop.storeKey).toBe('loops')
})
it.concurrent('should use correct config for parallel type', () => {
const CONFIG = {
parallel: {
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
typeKey: 'parallelType' as const,
storeKey: 'parallels' as const,
maxIterations: 20,
configKeys: {
iterations: 'count' as const,
items: 'distribution' as const,
},
},
}
expect(CONFIG.parallel.typeLabels.count).toBe('Parallel Count')
expect(CONFIG.parallel.typeLabels.collection).toBe('Parallel Each')
expect(CONFIG.parallel.maxIterations).toBe(20)
expect(CONFIG.parallel.storeKey).toBe('parallels')
})
})
describe('Type Determination Logic', () => {
it.concurrent('should default to "for" for loop type', () => {
type IterationType = 'loop' | 'parallel'
const determineDefaultType = (iterationType: IterationType) => {
return iterationType === 'loop' ? 'for' : 'count'
}
const currentType = determineDefaultType('loop')
expect(currentType).toBe('for')
})
it.concurrent('should default to "count" for parallel type', () => {
type IterationType = 'loop' | 'parallel'
const determineDefaultType = (iterationType: IterationType) => {
return iterationType === 'loop' ? 'for' : 'count'
}
const currentType = determineDefaultType('parallel')
expect(currentType).toBe('count')
})
it.concurrent('should use explicit loopType when provided', () => {
type IterationType = 'loop' | 'parallel'
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
}
const currentType = determineType('forEach', 'loop')
expect(currentType).toBe('forEach')
})
it.concurrent('should use explicit parallelType when provided', () => {
type IterationType = 'loop' | 'parallel'
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
}
const currentType = determineType('collection', 'parallel')
expect(currentType).toBe('collection')
})
})
describe('Count Mode Detection', () => {
it.concurrent('should be in count mode for loop + for combination', () => {
type IterationType = 'loop' | 'parallel'
const iterationType: IterationType = 'loop'
const currentType: LoopType = 'for'
const isCountMode = iterationType === 'loop' && currentType === 'for'
expect(isCountMode).toBe(true)
})
it.concurrent('should be in count mode for parallel + count combination', () => {
type IterationType = 'loop' | 'parallel'
const iterationType: IterationType = 'parallel'
const currentType: ParallelType = 'count'
const isCountMode = iterationType === 'parallel' && currentType === 'count'
expect(isCountMode).toBe(true)
})
it.concurrent('should not be in count mode for loop + forEach combination', () => {
type IterationType = 'loop' | 'parallel'
const testCountMode = (iterationType: IterationType, currentType: string) => {
return iterationType === 'loop' && currentType === 'for'
}
const isCountMode = testCountMode('loop', 'forEach')
expect(isCountMode).toBe(false)
})
it.concurrent('should not be in count mode for parallel + collection combination', () => {
type IterationType = 'loop' | 'parallel'
const testCountMode = (iterationType: IterationType, currentType: string) => {
return iterationType === 'parallel' && currentType === 'count'
}
const isCountMode = testCountMode('parallel', 'collection')
expect(isCountMode).toBe(false)
})
})
describe('Configuration Values', () => {
it.concurrent('should handle default iteration count', () => {
const data = { count: undefined }
const configIterations = data.count ?? 5
expect(configIterations).toBe(5)
})
it.concurrent('should use provided iteration count', () => {
const data = { count: 10 }
const configIterations = data.count ?? 5
expect(configIterations).toBe(10)
})
it.concurrent('should handle string collection', () => {
const collection = '[1, 2, 3, 4, 5]'
const collectionString =
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
expect(collectionString).toBe('[1, 2, 3, 4, 5]')
})
it.concurrent('should handle object collection', () => {
const collection = { items: [1, 2, 3] }
const collectionString =
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
expect(collectionString).toBe('{"items":[1,2,3]}')
})
it.concurrent('should handle array collection', () => {
const collection = [1, 2, 3, 4, 5]
const collectionString =
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
expect(collectionString).toBe('[1,2,3,4,5]')
})
})
describe('Preview Mode Handling', () => {
it.concurrent('should handle preview mode for loops', () => {
const previewProps = {
...defaultProps,
data: { ...defaultProps.data, isPreview: true },
iterationType: 'loop' as const,
}
expect(previewProps.data.isPreview).toBe(true)
// In preview mode, collaborative functions shouldn't be called
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).not.toHaveBeenCalled()
})
it.concurrent('should handle preview mode for parallels', () => {
const previewProps = {
...defaultProps,
data: { ...defaultProps.data, isPreview: true },
iterationType: 'parallel' as const,
}
expect(previewProps.data.isPreview).toBe(true)
// In preview mode, collaborative functions shouldn't be called
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).not.toHaveBeenCalled()
})
})
describe('Store Integration', () => {
it.concurrent('should access loops store for loop iteration type', () => {
const nodeId = 'loop-node-1'
;(mockStoreData.loops as any)[nodeId] = { iterations: 10 }
const nodeConfig = (mockStoreData.loops as any)[nodeId]
expect(nodeConfig).toBeDefined()
expect(nodeConfig.iterations).toBe(10)
})
it.concurrent('should access parallels store for parallel iteration type', () => {
const nodeId = 'parallel-node-1'
;(mockStoreData.parallels as any)[nodeId] = { count: 5 }
const nodeConfig = (mockStoreData.parallels as any)[nodeId]
expect(nodeConfig).toBeDefined()
expect(nodeConfig.count).toBe(5)
})
it.concurrent('should handle missing node configuration gracefully', () => {
const nodeId = 'missing-node'
const nodeConfig = (mockStoreData.loops as any)[nodeId]
expect(nodeConfig).toBeUndefined()
})
})
describe('Max Iterations Limits', () => {
it.concurrent('should enforce max iterations for loops (100)', () => {
const maxIterations = 100
const testValue = 150
const clampedValue = Math.min(maxIterations, testValue)
expect(clampedValue).toBe(100)
})
it.concurrent('should enforce max iterations for parallels (20)', () => {
const maxIterations = 20
const testValue = 50
const clampedValue = Math.min(maxIterations, testValue)
expect(clampedValue).toBe(20)
})
it.concurrent('should allow values within limits', () => {
const loopMaxIterations = 100
const parallelMaxIterations = 20
expect(Math.min(loopMaxIterations, 50)).toBe(50)
expect(Math.min(parallelMaxIterations, 10)).toBe(10)
})
})
describe('Collaborative Update Functions', () => {
it.concurrent('should have the correct collaborative functions available', () => {
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toBeDefined()
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toBeDefined()
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCount).toBeDefined()
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCollection).toBeDefined()
})
it.concurrent('should call correct function for loop type updates', () => {
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
if (iterationType === 'loop') {
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
} else {
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
}
}
handleTypeChange('forEach', 'loop', 'test-node')
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toHaveBeenCalledWith(
'test-node',
'forEach'
)
})
it.concurrent('should call correct function for parallel type updates', () => {
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
if (iterationType === 'loop') {
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
} else {
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
}
}
handleTypeChange('collection', 'parallel', 'test-node')
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toHaveBeenCalledWith(
'test-node',
'collection'
)
})
})
describe('Input Sanitization', () => {
it.concurrent('should sanitize numeric input by removing non-digits', () => {
const testInput = 'abc123def456'
const sanitized = testInput.replace(/[^0-9]/g, '')
expect(sanitized).toBe('123456')
})
it.concurrent('should handle empty input', () => {
const testInput = ''
const sanitized = testInput.replace(/[^0-9]/g, '')
expect(sanitized).toBe('')
})
it.concurrent('should preserve valid numeric input', () => {
const testInput = '42'
const sanitized = testInput.replace(/[^0-9]/g, '')
expect(sanitized).toBe('42')
})
})
})

View File

@@ -1,495 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import Editor from 'react-simple-code-editor'
import { highlight, languages } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
import {
isLikelyReferenceSegment,
SYSTEM_REFERENCE_PREFIXES,
splitReferenceSegment,
} from '@/lib/workflows/references'
import type { LoopType, ParallelType } from '@/lib/workflows/types'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { normalizeBlockName } from '@/stores/workflows/utils'
type IterationType = 'loop' | 'parallel'
interface IterationNodeData {
width?: number
height?: number
parentId?: string
state?: string
type?: string
extent?: 'parent'
loopType?: LoopType
parallelType?: ParallelType
// Common
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentIteration?: number
currentExecution?: number
isExecuting: boolean
startTime: number | null
endTime: number | null
}
}
interface IterationBadgesProps {
nodeId: string
data: IterationNodeData
iterationType: IterationType
}
const CONFIG = {
loop: {
typeLabels: {
for: 'For Loop',
forEach: 'For Each',
while: 'While Loop',
doWhile: 'Do While Loop',
},
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,
condition: 'whileCondition' as const,
} as any,
},
parallel: {
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
typeKey: 'parallelType' as const,
storeKey: 'parallels' as const,
maxIterations: 20,
configKeys: {
iterations: 'count' as const,
items: 'distribution' as const,
},
},
} as const
export function IterationBadges({ nodeId, data, iterationType }: IterationBadgesProps) {
const config = CONFIG[iterationType]
const isPreview = data?.isPreview || false
// Get configuration from the workflow store
const store = useWorkflowStore()
const nodeConfig = store[config.storeKey][nodeId]
// Determine current type and values
const currentType = (data?.[config.typeKey] ||
(iterationType === 'loop' ? 'for' : 'count')) as any
// Determine if we're in count mode, collection mode, or condition mode
const isCountMode =
(iterationType === 'loop' && currentType === 'for') ||
(iterationType === 'parallel' && currentType === 'count')
const isConditionMode =
iterationType === 'loop' && (currentType === 'while' || currentType === 'doWhile')
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
// Get condition based on loop type - same pattern as forEachItems
const conditionKey =
currentType === 'while'
? 'whileCondition'
: currentType === 'doWhile'
? 'doWhileCondition'
: null
const configCondition =
iterationType === 'loop' && conditionKey
? ((nodeConfig as any)?.[conditionKey] ?? (data as any)?.[conditionKey] ?? '')
: ''
const iterations = configIterations
const collectionString =
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
const conditionString = typeof configCondition === 'string' ? configCondition : ''
// State management
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const inputValue = tempInputValue ?? iterations.toString()
const editorValue = isConditionMode ? conditionString : collectionString
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
const [showTagDropdown, setShowTagDropdown] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement>(null)
// Get collaborative functions
const {
collaborativeUpdateLoopType,
collaborativeUpdateParallelType,
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
} = useCollaborativeWorkflow()
const accessiblePrefixes = useAccessibleReferencePrefixes(nodeId)
const shouldHighlightReference = useCallback(
(part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const reference = split.reference
if (!accessiblePrefixes) {
return true
}
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeBlockName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
return accessiblePrefixes.has(normalizedPrefix)
},
[accessiblePrefixes]
)
const highlightWithReferences = useCallback(
(code: string): string => {
const placeholders: Array<{
placeholder: string
original: string
type: 'var' | 'env'
}> = []
let processedCode = code
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
const placeholder = `__ENV_VAR_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'env' })
return placeholder
})
processedCode = processedCode.replace(/<[^>]+>/g, (match) => {
if (shouldHighlightReference(match)) {
const placeholder = `__VAR_REF_${placeholders.length}__`
placeholders.push({ placeholder, original: match, type: 'var' })
return placeholder
}
return match
})
let highlightedCode = highlight(processedCode, languages.javascript, 'javascript')
placeholders.forEach(({ placeholder, original, type }) => {
if (type === 'env') {
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${original}</span>`
)
} else {
const escaped = original.replace(/</g, '&lt;').replace(/>/g, '&gt;')
highlightedCode = highlightedCode.replace(
placeholder,
`<span class="text-blue-500">${escaped}</span>`
)
}
})
return highlightedCode
},
[shouldHighlightReference]
)
// Handle type change
const handleTypeChange = useCallback(
(newType: any) => {
if (isPreview) return
if (iterationType === 'loop') {
collaborativeUpdateLoopType(nodeId, newType)
} else {
collaborativeUpdateParallelType(nodeId, newType)
}
setTypePopoverOpen(false)
},
[nodeId, iterationType, collaborativeUpdateLoopType, collaborativeUpdateParallelType, isPreview]
)
// Handle iterations input change
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setTempInputValue(Math.min(config.maxIterations, numValue).toString())
} else {
setTempInputValue(sanitizedValue)
}
},
[isPreview, config.maxIterations]
)
// Handle iterations save
const handleIterationsSave = useCallback(() => {
if (isPreview) return
const value = Number.parseInt(inputValue)
if (!Number.isNaN(value)) {
const newValue = Math.min(config.maxIterations, Math.max(1, value))
collaborativeUpdateIterationCount(nodeId, iterationType, newValue)
}
setTempInputValue(null)
setConfigPopoverOpen(false)
}, [
inputValue,
nodeId,
iterationType,
collaborativeUpdateIterationCount,
isPreview,
config.maxIterations,
])
// Handle editor change
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return
collaborativeUpdateIterationCollection(nodeId, iterationType, value)
const textarea = editorContainerRef.current?.querySelector('textarea')
if (textarea) {
textareaRef.current = textarea
const cursorPos = textarea.selectionStart || 0
setCursorPosition(cursorPos)
const triggerCheck = checkTagTrigger(value, cursorPos)
setShowTagDropdown(triggerCheck.show)
}
},
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return
collaborativeUpdateIterationCollection(nodeId, iterationType, newValue)
setShowTagDropdown(false)
setTimeout(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
}
}, 0)
},
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
)
// Get type options
const typeOptions = Object.entries(config.typeLabels)
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Type Badge */}
<Popover
open={!isPreview && typePopoverOpen}
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{config.typeLabels[currentType as keyof typeof config.typeLabels]}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{iterationType === 'loop' ? 'Loop Type' : 'Parallel Type'}
</div>
<div className='space-y-1'>
{typeOptions.map(([typeValue, typeLabel]) => (
<div
key={typeValue}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
currentType === typeValue ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleTypeChange(typeValue)}
>
<span className='text-sm'>{typeLabel}</span>
</div>
))}
</div>
</div>
</PopoverContent>
)}
</Popover>
{/* Configuration Badge */}
<Popover
open={!isPreview && configPopoverOpen}
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{isCountMode ? `Iterations: ${iterations}` : isConditionMode ? 'Condition' : 'Items'}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent
className={cn('p-3', !isCountMode ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{isCountMode
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: isConditionMode
? 'While Condition'
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
</div>
{isCountMode ? (
// Number input for count-based mode
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
</div>
) : isConditionMode ? (
// Code editor for while condition
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{conditionString === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
{'<counter.value> < 10'}
</div>
)}
<Editor
value={conditionString}
onValueChange={handleEditorChange}
highlight={highlightWithReferences}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
JavaScript expression that evaluates to true/false. Type "{'<'}" to reference
blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={conditionString}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
) : (
// Code editor for collection-based mode
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
['item1', 'item2', 'item3']
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={highlightWithReferences}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to iterate over. Type "{'<'}" to reference other blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
)}
{isCountMode && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and {config.maxIterations}
</div>
)}
</div>
</PopoverContent>
)}
</Popover>
</div>
)
}

View File

@@ -1,31 +1,12 @@
import { RepeatIcon } from 'lucide-react'
/**
* Loop tool configuration for the toolbar.
* Defines the visual appearance of the Loop subflow container in the toolbar.
*/
export const LoopTool = {
id: 'loop',
type: 'loop',
name: 'Loop',
description: 'Create a Loop',
icon: RepeatIcon,
bgColor: '#2FB3FF',
data: {
label: 'Loop',
loopType: 'for',
count: 5,
collection: '',
width: 500,
height: 300,
extent: 'parent',
executionState: {
currentIteration: 0,
isExecuting: false,
startTime: null,
endTime: null,
},
},
style: {
width: 500,
height: 300,
},
// Specify that this should be rendered as a ReactFlow group node
isResizable: true,
}
} as const

View File

@@ -1,29 +1,12 @@
import { SplitIcon } from 'lucide-react'
/**
* Parallel tool configuration for the toolbar.
* Defines the visual appearance of the Parallel subflow container in the toolbar.
*/
export const ParallelTool = {
id: 'parallel',
type: 'parallel',
name: 'Parallel',
description: 'Parallel Execution',
icon: SplitIcon,
bgColor: '#FEE12B',
data: {
label: 'Parallel',
parallelType: 'count' as 'collection' | 'count',
count: 5,
collection: '',
extent: 'parent',
executionState: {
currentExecution: 0,
isExecuting: false,
startTime: null,
endTime: null,
},
},
style: {
width: 500,
height: 300,
},
// Specify that this should be rendered as a ReactFlow group node
isResizable: true,
}
} as const

View File

@@ -1,579 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
// Shared spies used across mocks
const mockRemoveBlock = vi.fn()
const mockGetNodes = vi.fn()
// Mocks
vi.mock('@/hooks/use-collaborative-workflow', () => ({
useCollaborativeWorkflow: vi.fn(() => ({
collaborativeRemoveBlock: mockRemoveBlock,
})),
}))
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}))
vi.mock('reactflow', () => ({
Handle: ({ id, type, position }: any) => ({ id, type, position }),
Position: {
Top: 'top',
Bottom: 'bottom',
Left: 'left',
Right: 'right',
},
useReactFlow: () => ({
getNodes: mockGetNodes,
}),
memo: (component: any) => component,
}))
vi.mock('react', async () => {
const actual = await vi.importActual<any>('react')
return {
...actual,
memo: (component: any) => component,
useMemo: (fn: any) => fn(),
useRef: () => ({ current: null }),
}
})
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
}))
vi.mock('@/components/ui/card', () => ({
Card: ({ children, ...props }: any) => ({ children, ...props }),
}))
vi.mock('@/components/icons', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
StartIcon: ({ className }: any) => ({ className }),
}
})
vi.mock('@/lib/utils', () => ({
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges',
() => ({
IterationBadges: ({ nodeId, iterationType }: any) => ({ nodeId, iterationType }),
})
)
describe('SubflowNodeComponent', () => {
const defaultProps = {
id: 'subflow-1',
type: 'subflowNode',
data: {
width: 500,
height: 300,
isPreview: false,
kind: 'loop' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
beforeEach(() => {
vi.clearAllMocks()
mockGetNodes.mockReturnValue([])
})
describe('Component Definition and Structure', () => {
it.concurrent('should be defined as a function component', () => {
expect(SubflowNodeComponent).toBeDefined()
expect(typeof SubflowNodeComponent).toBe('function')
})
it.concurrent('should have correct display name', () => {
expect(SubflowNodeComponent.displayName).toBe('SubflowNodeComponent')
})
it.concurrent('should be a memoized component', () => {
expect(SubflowNodeComponent).toBeDefined()
})
})
describe('Props Validation and Type Safety', () => {
it.concurrent('should accept NodeProps interface', () => {
const validProps = {
id: 'test-id',
type: 'subflowNode' as const,
data: {
width: 400,
height: 300,
isPreview: true,
kind: 'parallel' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
expect(() => {
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
expect(_component).toBeDefined()
expect(validProps.type).toBe('subflowNode')
}).not.toThrow()
})
it.concurrent('should handle different data configurations', () => {
const configurations = [
{ width: 500, height: 300, isPreview: false, kind: 'loop' as const },
{ width: 800, height: 600, isPreview: true, kind: 'parallel' as const },
{ width: 0, height: 0, isPreview: false, kind: 'loop' as const },
{ kind: 'loop' as const },
]
configurations.forEach((data) => {
const props = { ...defaultProps, data }
expect(() => {
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
expect(_component).toBeDefined()
expect(props.data).toBeDefined()
}).not.toThrow()
})
})
})
describe('Hook Integration', () => {
it.concurrent('should provide collaborativeRemoveBlock', () => {
expect(mockRemoveBlock).toBeDefined()
expect(typeof mockRemoveBlock).toBe('function')
mockRemoveBlock('test-id')
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
})
describe('Component Logic Tests', () => {
it.concurrent('should handle nesting level calculation logic', () => {
const testCases = [
{ nodes: [], parentId: undefined, expectedLevel: 0 },
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
{
nodes: [
{ id: 'parent', data: { parentId: 'grandparent' } },
{ id: 'grandparent', data: {} },
],
parentId: 'parent',
expectedLevel: 2,
},
]
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
mockGetNodes.mockReturnValue(nodes)
// Simulate the nesting level calculation logic
let level = 0
let currentParentId = parentId
while (currentParentId) {
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(expectedLevel)
})
})
it.concurrent('should handle nested styles generation', () => {
// Test the nested styles logic
const testCases = [
{ nestingLevel: 0, expectedBg: 'rgba(34,197,94,0.05)' },
{ nestingLevel: 1, expectedBg: '#e2e8f030' },
{ nestingLevel: 2, expectedBg: '#cbd5e130' },
]
testCases.forEach(({ nestingLevel, expectedBg }) => {
// Simulate the getNestedStyles logic
const styles: Record<string, string> = {
backgroundColor: 'rgba(34,197,94,0.05)',
}
if (nestingLevel > 0) {
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30`
}
expect(styles.backgroundColor).toBe(expectedBg)
})
})
})
describe('Component Configuration', () => {
it.concurrent('should handle different dimensions', () => {
const dimensionTests = [
{ width: 500, height: 300 },
{ width: 800, height: 600 },
{ width: 0, height: 0 },
{ width: 10000, height: 10000 },
]
dimensionTests.forEach(({ width, height }) => {
const data = { width, height }
expect(data.width).toBe(width)
expect(data.height).toBe(height)
})
})
})
describe('Event Handling Logic', () => {
it.concurrent('should handle delete button click logic (simulated)', () => {
const mockEvent = { stopPropagation: vi.fn() }
const handleDelete = (e: any, nodeId: string) => {
e.stopPropagation()
mockRemoveBlock(nodeId)
}
handleDelete(mockEvent, 'test-id')
expect(mockEvent.stopPropagation).toHaveBeenCalled()
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
it.concurrent('should handle event propagation prevention', () => {
const mockEvent = { stopPropagation: vi.fn() }
mockEvent.stopPropagation()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
})
describe('Component Data Handling', () => {
it.concurrent('should handle missing data properties gracefully', () => {
const testCases = [
undefined,
{},
{ width: 500 },
{ height: 300 },
{ width: 500, height: 300 },
]
testCases.forEach((data: any) => {
const props = { ...defaultProps, data }
const width = Math.max(0, data?.width || 500)
const height = Math.max(0, data?.height || 300)
expect(width).toBeGreaterThanOrEqual(0)
expect(height).toBeGreaterThanOrEqual(0)
expect(props.type).toBe('subflowNode')
})
})
it.concurrent('should handle parent ID relationships', () => {
const testCases = [
{ parentId: undefined, hasParent: false },
{ parentId: 'parent-1', hasParent: true },
{ parentId: '', hasParent: false },
]
testCases.forEach(({ parentId, hasParent }) => {
const data = { ...defaultProps.data, parentId }
expect(Boolean(data.parentId)).toBe(hasParent)
})
})
})
describe('Loop vs Parallel Kind Specific Tests', () => {
it.concurrent('should generate correct handle IDs for loop kind', () => {
const loopData = { ...defaultProps.data, kind: 'loop' as const }
const startHandleId = loopData.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = loopData.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
expect(startHandleId).toBe('loop-start-source')
expect(endHandleId).toBe('loop-end-source')
})
it.concurrent('should generate correct handle IDs for parallel kind', () => {
type SubflowKind = 'loop' | 'parallel'
const testHandleGeneration = (kind: SubflowKind) => {
const startHandleId = kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
return { startHandleId, endHandleId }
}
const result = testHandleGeneration('parallel')
expect(result.startHandleId).toBe('parallel-start-source')
expect(result.endHandleId).toBe('parallel-end-source')
})
it.concurrent('should generate correct background colors for loop kind', () => {
const loopData = { ...defaultProps.data, kind: 'loop' as const }
const startBg = loopData.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
expect(startBg).toBe('#2FB3FF')
})
it.concurrent('should generate correct background colors for parallel kind', () => {
type SubflowKind = 'loop' | 'parallel'
const testBgGeneration = (kind: SubflowKind) => {
return kind === 'loop' ? '#2FB3FF' : '#FEE12B'
}
const startBg = testBgGeneration('parallel')
expect(startBg).toBe('#FEE12B')
})
it.concurrent('should demonstrate handle ID generation for any kind', () => {
type SubflowKind = 'loop' | 'parallel'
const testKind = (kind: SubflowKind) => {
const data = { kind }
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
return { startHandleId, endHandleId }
}
const loopResult = testKind('loop')
expect(loopResult.startHandleId).toBe('loop-start-source')
expect(loopResult.endHandleId).toBe('loop-end-source')
const parallelResult = testKind('parallel')
expect(parallelResult.startHandleId).toBe('parallel-start-source')
expect(parallelResult.endHandleId).toBe('parallel-end-source')
})
it.concurrent('should pass correct iterationType to IterationBadges for loop', () => {
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
// Mock IterationBadges should receive the kind as iterationType
expect(loopProps.data.kind).toBe('loop')
})
it.concurrent('should pass correct iterationType to IterationBadges for parallel', () => {
const parallelProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
// Mock IterationBadges should receive the kind as iterationType
expect(parallelProps.data.kind).toBe('parallel')
})
it.concurrent('should handle both kinds in configuration arrays', () => {
const bothKinds = ['loop', 'parallel'] as const
bothKinds.forEach((kind) => {
const data = { ...defaultProps.data, kind }
expect(['loop', 'parallel']).toContain(data.kind)
// Test handle ID generation for both kinds
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
if (kind === 'loop') {
expect(startHandleId).toBe('loop-start-source')
expect(endHandleId).toBe('loop-end-source')
expect(startBg).toBe('#2FB3FF')
} else {
expect(startHandleId).toBe('parallel-start-source')
expect(endHandleId).toBe('parallel-end-source')
expect(startBg).toBe('#FEE12B')
}
})
})
it.concurrent('should maintain consistent styling behavior across both kinds', () => {
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
const parallelProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
// Both should have same base properties except kind-specific ones
expect(loopProps.data.width).toBe(parallelProps.data.width)
expect(loopProps.data.height).toBe(parallelProps.data.height)
expect(loopProps.data.isPreview).toBe(parallelProps.data.isPreview)
// But different kinds
expect(loopProps.data.kind).toBe('loop')
expect(parallelProps.data.kind).toBe('parallel')
})
})
describe('Integration with IterationBadges', () => {
it.concurrent('should pass nodeId to IterationBadges', () => {
const testId = 'test-subflow-123'
const props = { ...defaultProps, id: testId }
// Verify the props would be passed correctly
expect(props.id).toBe(testId)
})
it.concurrent('should pass data object to IterationBadges', () => {
const testData = { ...defaultProps.data, customProperty: 'test' }
const props = { ...defaultProps, data: testData }
// Verify the data object structure
expect(props.data).toEqual(testData)
expect(props.data.kind).toBeDefined()
})
it.concurrent('should pass iterationType matching the kind', () => {
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
const parallelProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
// The iterationType should match the kind
expect(loopProps.data.kind).toBe('loop')
expect(parallelProps.data.kind).toBe('parallel')
})
})
describe('CSS Class Generation', () => {
it.concurrent('should generate proper CSS classes for nested loops', () => {
const nestingLevel = 2
const expectedBorderClass =
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
expect(expectedBorderClass).toBeTruthy()
expect(expectedBorderClass).toContain('border-slate-300/60') // even nesting level
})
it.concurrent('should generate proper CSS classes for odd nested levels', () => {
const nestingLevel = 3
const expectedBorderClass =
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
expect(expectedBorderClass).toBeTruthy()
expect(expectedBorderClass).toContain('border-slate-400/60') // odd nesting level
})
it.concurrent('should handle error state styling', () => {
const hasNestedError = true
const errorClasses = hasNestedError && 'border-2 border-red-500 bg-red-50/50'
expect(errorClasses).toBe('border-2 border-red-500 bg-red-50/50')
})
it.concurrent('should handle diff status styling', () => {
const diffStatuses = ['new', 'edited'] as const
diffStatuses.forEach((status) => {
let diffClass = ''
if (status === 'new') {
diffClass = 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10'
} else if (status === 'edited') {
diffClass = 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
}
expect(diffClass).toBeTruthy()
if (status === 'new') {
expect(diffClass).toContain('ring-green-500')
} else {
expect(diffClass).toContain('ring-orange-500')
}
})
})
})
describe('Edge Cases and Error Handling', () => {
it.concurrent('should handle circular parent references', () => {
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node1' } },
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(2)
expect(visited.has('node1')).toBe(true)
expect(visited.has('node2')).toBe(true)
})
it.concurrent('should handle complex circular reference chains', () => {
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node3' } },
{ id: 'node3', data: { parentId: 'node1' } },
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(3)
expect(visited.size).toBe(3)
})
it.concurrent('should handle self-referencing nodes', () => {
const nodes = [{ id: 'node1', data: { parentId: 'node1' } }]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(1)
expect(visited.has('node1')).toBe(true)
})
})
})

View File

@@ -1,77 +1,63 @@
import type React from 'react'
import { memo, useMemo, useRef } from 'react'
import { Trash2 } from 'lucide-react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { StartIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Button, Trash } from '@/components/emcn'
import { cn } from '@/lib/utils'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
/**
* Global styles for subflow nodes (loop and parallel containers).
* Includes animations for drag-over states and hover effects.
*
* @returns Style component with global CSS
*/
const SubflowNodeStyles: React.FC = () => {
return (
<style jsx global>{`
@keyframes loop-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(47, 179, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0); }
}
@keyframes parallel-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(139, 195, 74, 0); }
100% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0); }
}
.loop-node-drag-over {
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(47, 179, 255, 0.08) !important;
box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1);
/* Z-index management for subflow nodes */
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
/* Drag-over states */
.loop-node-drag-over,
.parallel-node-drag-over {
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(139, 195, 74, 0.08) !important;
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
}
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
box-shadow: 0 0 0 1.75px #33B4FF !important;
border-radius: 8px !important;
}
/* Handle z-index for nested nodes */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
.react-flow__node-group.dragging-over {
background-color: rgba(34,197,94,0.05);
transition: all 0.2s ease-in-out;
}
`}</style>
)
}
/**
* Data structure for subflow nodes (loop and parallel containers)
*/
export interface SubflowNodeData {
width?: number
height?: number
parentId?: string
extent?: 'parent'
hasNestedError?: boolean
isPreview?: boolean
kind: 'loop' | 'parallel'
name?: string
}
/**
* Subflow node component for loop and parallel execution containers.
* Renders a resizable container with a header displaying the block name and icon,
* handles for connections, and supports nested execution contexts.
*
* @param props - Node properties containing data and id
* @returns Rendered subflow node component
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
@@ -86,6 +72,15 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
const isPreview = data?.isPreview || false
// Focus state
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
/**
* Calculate the nesting level of this subflow node based on its parent hierarchy.
* Used to apply appropriate styling for nested containers.
*/
const nestingLevel = useMemo(() => {
let level = 0
let currentParentId = data?.parentId
@@ -100,69 +95,112 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
return level
}, [id, data?.parentId, getNodes])
const getNestedStyles = () => {
const styles: Record<string, string> = {
backgroundColor: 'rgba(0, 0, 0, 0.02)',
}
if (nestingLevel > 0) {
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30`
}
return styles
}
const nestedStyles = getNestedStyles()
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
/**
* Reusable styles and positioning for Handle components.
* Matches the styling pattern from workflow-block.tsx.
*/
const getHandleClasses = (position: 'left' | 'right') => {
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
const colorClasses = '!bg-[#434343]'
const positionClasses = {
left: '!left-[-7px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-10px] hover:!w-[10px] hover:!rounded-l-full',
right:
'!right-[-7px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-10px] hover:!w-[10px] hover:!rounded-r-full',
}
return cn(baseClasses, colorClasses, positionClasses[position])
}
const getHandleStyle = () => {
return { top: '20px', transform: 'translateY(-50%)' }
}
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor) - blue ring
* 2. Diff status (version comparison) - green/orange ring
*/
const hasRing = isFocused || diffStatus === 'new' || diffStatus === 'edited'
const ringStyles = cn(
hasRing && 'ring-[1.75px]',
isFocused && 'ring-[#33B4FF]',
diffStatus === 'new' && 'ring-[#22C55F]',
diffStatus === 'edited' && 'ring-[#FF6600]'
)
return (
<>
<SubflowNodeStyles />
<div className='group relative'>
<Card
<div
ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn(
'relative cursor-default select-none',
'relative cursor-default select-none rounded-[8px] border border-[#393939]',
'transition-block-bg transition-ring',
'z-[20]',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
diffStatus === 'edited' &&
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
'z-[20]'
)}
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
...nestedStyles,
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
>
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Header Section */}
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[#393939] border-b bg-[#232323] py-[8px] pr-[12px] pl-[8px] dark:bg-[#232323] [&:active]:cursor-grabbing'
)}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
{blockName}
</span>
</div>
{!isPreview && (
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
collaborativeRemoveBlock(id)
}}
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
)}
</div>
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
<div
className='h-[calc(100%-10px)] p-4'
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
data-dragarea='true'
style={{
position: 'relative',
@@ -170,38 +208,22 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
collaborativeRemoveBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Subflow Start */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto', backgroundColor: startBg }}
className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] bg-[#232323] px-[12px] py-[6px]'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
<span className='font-medium text-[14px] text-white'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
className={getHandleClasses('right')}
style={{
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
@@ -215,11 +237,9 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
<Handle
type='target'
position={Position.Left}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
className={getHandleClasses('left')}
style={{
left: '-7px',
top: '50%',
transform: 'translateY(-50%)',
...getHandleStyle(),
pointerEvents: 'auto',
}}
/>
@@ -228,18 +248,20 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
<Handle
type='source'
position={Position.Right}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
className={getHandleClasses('right')}
style={{
right: '-7px',
top: '50%',
transform: 'translateY(-50%)',
...getHandleStyle(),
pointerEvents: 'auto',
}}
id={endHandleId}
/>
<IterationBadges nodeId={id} data={data} iterationType={data.kind} />
</Card>
{hasRing && (
<div
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
/>
)}
</div>
</div>
</>
)

View File

@@ -23,7 +23,7 @@ import {
useWebhookInfo,
} from './hooks'
import type { WorkflowBlockProps } from './types'
import { debounce, getProviderName, shouldSkipBlockRender } from './utils'
import { getProviderName, shouldSkipBlockRender } from './utils'
const logger = createLogger('WorkflowBlock')
@@ -287,6 +287,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
const currentStoreBlock = currentWorkflow.getBlockById(id)
const isStarterBlock = type === 'starter'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
@@ -299,55 +300,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
updateNodeInternals(id)
}, [id, horizontalHandles, updateNodeInternals])
/**
* Debounced layout update function that only triggers when dimensions
* actually change to avoid unnecessary re-renders.
*/
const debouncedLayoutUpdate = useMemo(
() =>
debounce((dimensions: { width: number; height: number }) => {
if (dimensions.height !== blockHeight || dimensions.width !== blockWidth) {
updateBlockLayoutMetrics(id, dimensions)
updateNodeInternals(id)
}
}, 100),
[blockHeight, blockWidth, updateBlockLayoutMetrics, updateNodeInternals, id]
)
/**
* Use ResizeObserver to track block size changes and update layout metrics.
* Schedules updates on animation frames for better performance.
*/
useEffect(() => {
if (!contentRef.current) return
let rafId: number
const resizeObserver = new ResizeObserver((entries) => {
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
for (const entry of entries) {
const rect = entry.target.getBoundingClientRect()
const height = entry.borderBoxSize[0]?.blockSize ?? rect.height
const width = entry.borderBoxSize[0]?.inlineSize ?? rect.width
debouncedLayoutUpdate({ width, height })
}
})
})
resizeObserver.observe(contentRef.current)
return () => {
resizeObserver.disconnect()
if (rafId) {
cancelAnimationFrame(rafId)
}
}
}, [debouncedLayoutUpdate])
/**
* Subscribe to this block's subblock values to track changes for conditional rendering
* of subblocks based on their conditions.
@@ -555,6 +507,64 @@ export const WorkflowBlock = memo(function WorkflowBlock({
]
}, [type, subBlockState, id])
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
*
* Height model:
* - Fixed header height: 40px
* - Content padding when present: 16px (8 top + 8 bottom)
* - Row height: 29px per rendered row (subblock rows, condition rows, plus error row if present)
*
* Width is a fixed 250px for workflow blocks.
*/
useEffect(() => {
// Only workflow blocks (non-subflow) render here, width is constant
const FIXED_WIDTH = 250
const HEADER_HEIGHT = 40
const CONTENT_PADDING = 16
const ROW_HEIGHT = 29
const shouldShowDefaultHandles =
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
// Count rows based on block type and whether default handles section is shown
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
}
const contentHeight = hasContentBelowHeader ? CONTENT_PADDING + rowsCount * ROW_HEIGHT : 0
const calculatedHeight = Math.max(HEADER_HEIGHT + contentHeight, 100)
const prevHeight =
typeof currentStoreBlock?.height === 'number' ? currentStoreBlock.height : undefined
const prevWidth = 250 // fixed across the app for workflow blocks
// Only update store if something actually changed to prevent unnecessary reflows
if (prevHeight !== calculatedHeight || prevWidth !== FIXED_WIDTH) {
updateBlockLayoutMetrics(id, { width: FIXED_WIDTH, height: calculatedHeight })
updateNodeInternals(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
id,
type,
config.category,
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
currentStoreBlock?.height,
updateBlockLayoutMetrics,
updateNodeInternals,
])
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
const shouldShowScheduleBadge =
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
@@ -623,7 +633,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between px-[9px] py-[8px] [&:active]:cursor-grabbing',
'workflow-drag-handle flex cursor-grab items-center justify-between p-[8px] [&:active]:cursor-grabbing',
hasContentBelowHeader && 'border-[#393939] border-b'
)}
onMouseDown={(e) => {

View File

@@ -14,25 +14,20 @@ export function useNodeUtilities(blocks: Record<string, any>) {
const { getNodes, project } = useReactFlow()
/**
* Check if a block is a container type
* Check if a block is a container type (loop, parallel, or subflow)
*/
const isContainerType = useCallback((blockType: string): boolean => {
return (
blockType === 'loop' ||
blockType === 'parallel' ||
blockType === 'loopNode' ||
blockType === 'parallelNode' ||
blockType === 'subflowNode'
)
return blockType === 'loop' || blockType === 'parallel' || blockType === 'subflowNode'
}, [])
/**
* Get the dimensions of a block
* Get the dimensions of a block.
* For regular blocks, estimates height if not yet measured by ResizeObserver.
*/
const getBlockDimensions = useCallback(
(blockId: string): { width: number; height: number } => {
const block = blocks[blockId]
if (!block) return { width: 350, height: 150 }
if (!block) return { width: 250, height: 100 }
if (isContainerType(block.type)) {
return {
@@ -41,12 +36,31 @@ export function useNodeUtilities(blocks: Record<string, any>) {
}
}
// Workflow block nodes have fixed visual width
const width = 250
// Prefer deterministic height published by the block component; fallback to estimate
let height = block.height
if (!height) {
// Estimate height for workflow blocks before ResizeObserver measures them
// Block structure: header (40px) + content area with subblocks
// Each subblock row is approximately 29px (14px text + 8px gap + padding)
const headerHeight = 40
const subblockRowHeight = 29
const contentPadding = 16 // p-[8px] top and bottom = 16px total
// Estimate number of visible subblock rows
// This is a rough estimate - actual rendering may vary
const estimatedRows = 3 // Conservative estimate for typical blocks
const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0
height = headerHeight + contentPadding + (estimatedRows + hasErrorRow) * subblockRowHeight
}
return {
width: block.layout?.measuredWidth || block.data?.width || 350,
height: Math.max(
block.layout?.measuredHeight || block.height || block.data?.height || 150,
100
),
width,
height: Math.max(height, 100),
}
},
[blocks, isContainerType]
@@ -86,7 +100,8 @@ export function useNodeUtilities(blocks: Record<string, any>) {
)
/**
* Gets the absolute position of a node (accounting for nested parents)
* Gets the absolute position of a node (accounting for nested parents).
* For nodes inside containers, accounts for header and padding offsets.
* @param nodeId ID of the node to check
* @returns Absolute position coordinates {x, y}
*/
@@ -129,28 +144,41 @@ export function useNodeUtilities(blocks: Record<string, any>) {
const parentPos = getNodeAbsolutePosition(parentId)
// Child positions are stored relative to the content area (after header and padding)
// Add these offsets when calculating absolute position
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
return {
x: parentPos.x + node.position.x,
y: parentPos.y + node.position.y,
x: parentPos.x + leftPadding + node.position.x,
y: parentPos.y + headerHeight + topPadding + node.position.y,
}
},
[getNodes, blocks]
)
/**
* Calculates the relative position of a node to a new parent
* Calculates the relative position of a node to a new parent's content area.
* Accounts for header height and padding offsets in container nodes.
* @param nodeId ID of the node being repositioned
* @param newParentId ID of the new parent
* @returns Relative position coordinates {x, y}
* @returns Relative position coordinates {x, y} within the parent's content area
*/
const calculateRelativePosition = useCallback(
(nodeId: string, newParentId: string): { x: number; y: number } => {
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
const parentAbsPos = getNodeAbsolutePosition(newParentId)
// Account for container's header and padding
// Children are positioned relative to content area, not container origin
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
return {
x: nodeAbsPos.x - parentAbsPos.x,
y: nodeAbsPos.y - parentAbsPos.y,
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
}
},
[getNodeAbsolutePosition]
@@ -222,37 +250,46 @@ export function useNodeUtilities(blocks: Record<string, any>) {
const minWidth = DEFAULT_CONTAINER_WIDTH
const minHeight = DEFAULT_CONTAINER_HEIGHT
// Match styling in subflow-node.tsx:
// - Header section: 50px total height
// - Content area: px-[16px] pb-[0px] pt-[16px] pr-[70px]
// Left padding: 16px, Right padding: 64px, Top padding: 16px, Bottom padding: -6px (reduced by additional 6px from 0 to achieve 14px total reduction from original 8px)
// - Children are positioned relative to the content area (after header, inside padding)
const headerHeight = 50
const leftPadding = 16
const rightPadding = 80
const topPadding = 16
const bottomPadding = 16
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
if (childNodes.length === 0) {
return { width: minWidth, height: minHeight }
}
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxRight = 0
let maxBottom = 0
childNodes.forEach((node) => {
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
minX = Math.min(minX, node.position.x + nodeWidth)
minY = Math.min(minY, node.position.y + nodeHeight)
maxX = Math.max(maxX, node.position.x + nodeWidth)
maxY = Math.max(maxY, node.position.y + nodeHeight + 50)
// Child positions are relative to content area's inner top-left (inside padding)
// Calculate the rightmost and bottommost edges of children
const rightEdge = node.position.x + nodeWidth
const bottomEdge = node.position.y + nodeHeight
maxRight = Math.max(maxRight, rightEdge)
maxBottom = Math.max(maxBottom, bottomEdge)
})
const hasNestedContainers = childNodes.some((node) => node.type && isContainerType(node.type))
const sidePadding = hasNestedContainers ? 150 : 120
const extraPadding = 50
const width = Math.max(minWidth, maxX + sidePadding + extraPadding)
const height = Math.max(minHeight, maxY + sidePadding)
// Container dimensions = header + padding + children bounds + padding
// Width: left padding + max child right edge + right padding (64px)
const width = Math.max(minWidth, leftPadding + maxRight + rightPadding)
// Height: header + top padding + max child bottom edge + bottom padding (8px)
const height = Math.max(minHeight, headerHeight + topPadding + maxBottom + bottomPadding)
return { width, height }
},
[getNodes, getBlockDimensions, isContainerType]
[getNodes, getBlockDimensions]
)
/**
@@ -343,7 +380,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
: 500
: typeof node.width === 'number'
? node.width
: 350
: 250
const height = isSubflow
? typeof node.data?.height === 'number'
? node.data.height

View File

@@ -86,9 +86,6 @@ const WorkflowContent = React.memo(() => {
// State for tracking node dragging
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
// State for tracking validation errors
// Use a function initializer to ensure the Set is only created once
const [nestedSubflowErrors, setNestedSubflowErrors] = useState<Set<string>>(() => new Set())
// Enhanced edge selection with parent context and unique identifier
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null)
@@ -128,6 +125,8 @@ const WorkflowContent = React.memo(() => {
getDragStartPosition,
} = useWorkflowStore()
// (moved) resizeLoopNodesWrapper defined after hooks that provide resizeLoopNodes
// Get copilot cleanup function
const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -148,6 +147,14 @@ const WorkflowContent = React.memo(() => {
getNodeAnchorPosition,
} = useNodeUtilities(blocks)
/**
* Wrapper to call resizeLoopNodes with immediate execution.
* No delays for responsive subflow resizing.
*/
const resizeLoopNodesWrapper = useCallback(() => {
return resizeLoopNodes(updateNodeDimensions)
}, [resizeLoopNodes, updateNodeDimensions])
// Auto-layout hook
const { applyAutoLayoutAndUpdateStore } = useAutoLayout(activeWorkflowId || null)
@@ -279,29 +286,6 @@ const WorkflowContent = React.memo(() => {
}
}, [isErrorConnectionDrag])
// Helper function to validate workflow for nested subflows
const validateNestedSubflows = useCallback(() => {
const errors = new Set<string>()
Object.entries(blocks).forEach(([blockId, block]) => {
// Check if this is a subflow block (loop or parallel)
if (block.type === 'loop' || block.type === 'parallel') {
// Check if it has a parent that is also a subflow block
const parentId = block.data?.parentId
if (parentId) {
const parentBlock = blocks[parentId]
if (parentBlock && (parentBlock.type === 'loop' || parentBlock.type === 'parallel')) {
// This is a nested subflow - mark as error
errors.add(blockId)
}
}
}
})
setNestedSubflowErrors(errors)
return errors.size === 0
}, [blocks])
// Log permissions when they load
useEffect(() => {
if (workspacePermissions) {
@@ -347,9 +331,13 @@ const WorkflowContent = React.memo(() => {
if (newParentId) {
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
const parentAbsPos = getNodeAbsolutePosition(newParentId)
// Account for header (50px), left padding (16px), and top padding (16px)
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
newPosition = {
x: nodeAbsPos.x - parentAbsPos.x,
y: nodeAbsPos.y - parentAbsPos.y,
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
}
} else if (oldParentId) {
newPosition = getNodeAbsolutePosition(nodeId)
@@ -360,7 +348,7 @@ const WorkflowContent = React.memo(() => {
newParentId,
collaborativeUpdateBlockPosition,
updateParentId,
() => resizeLoopNodes(updateNodeDimensions)
() => resizeLoopNodesWrapper()
)
if (oldParentId !== newParentId) {
@@ -389,15 +377,10 @@ const WorkflowContent = React.memo(() => {
edgesForDisplay,
getNodeAbsolutePosition,
updateNodeParentUtil,
resizeLoopNodes,
resizeLoopNodesWrapper,
]
)
// Wrapper to call resizeLoopNodes with updateNodeDimensions
const resizeLoopNodesWrapper = useCallback(() => {
return resizeLoopNodes(updateNodeDimensions)
}, [resizeLoopNodes, updateNodeDimensions])
// Auto-layout handler - uses the hook for immediate frontend updates
const handleAutoLayout = useCallback(async () => {
if (Object.keys(blocks).length === 0) return
@@ -581,13 +564,10 @@ const WorkflowContent = React.memo(() => {
// Special handling for container nodes (loop or parallel)
if (type === 'loop' || type === 'parallel') {
// Create a unique ID and name for the container
const id = crypto.randomUUID()
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
// Calculate the center position of the viewport
const centerPosition = project({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
@@ -599,15 +579,7 @@ const WorkflowContent = React.memo(() => {
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(centerPosition)
if (closestBlock) {
// Container nodes are never triggers, but check if source is a trigger
const sourceBlockConfig = getBlock(closestBlock.type)
const isSourceTrigger =
sourceBlockConfig?.category === 'triggers' || sourceBlockConfig?.triggers?.enabled
// Container nodes can connect from triggers (they're not triggers themselves)
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
@@ -619,7 +591,7 @@ const WorkflowContent = React.memo(() => {
}
}
// Add the container node directly to canvas with default dimensions and auto-connect edge
// Add the container node with default dimensions and auto-connect edge
addBlock(
id,
type,
@@ -810,77 +782,56 @@ const WorkflowContent = React.memo(() => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
})
document.body.style.cursor = ''
// Ensure any toolbar drag flags are cleared on drop
document.body.classList.remove('sim-drag-subflow')
// Special handling for container nodes (loop or parallel)
if (data.type === 'loop' || data.type === 'parallel') {
// Create a unique ID and name for the container
const id = crypto.randomUUID()
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
// Check if we're dropping inside another container
if (containerInfo) {
// Calculate position relative to the parent container
const relativePosition = {
x: position.x - containerInfo.loopPosition.x,
y: position.y - containerInfo.loopPosition.y,
// Subflows cannot be dropped inside other subflows - always add to main canvas
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
// Add the container as a child of the parent container (will be marked as error)
addBlock(id, data.type, name, relativePosition, {
// Add the container node with default dimensions and auto-connect edge
addBlock(
id,
data.type,
name,
position,
{
width: 500,
height: 300,
type: 'subflowNode',
parentId: containerInfo.loopId,
extent: 'parent',
})
// Resize the parent container to fit the new child container
resizeLoopNodesWrapper()
} else {
// Auto-connect the container to the closest node on the canvas
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
// Container nodes can connect from any block (they're never triggers)
const sourceHandle = determineSourceHandle(closestBlock)
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
}
}
}
// Add the container node directly to canvas with default dimensions and auto-connect edge
addBlock(
id,
data.type,
name,
position,
{
width: 500,
height: 300,
type: 'subflowNode',
},
undefined,
undefined,
autoConnectEdge
)
}
},
undefined,
undefined,
autoConnectEdge
)
return
}
// Validate block config for regular blocks
const blockConfig = getBlock(data.type)
if (!blockConfig && data.type !== 'loop' && data.type !== 'parallel') {
if (!blockConfig) {
logger.error('Invalid block type:', { data })
return
}
@@ -898,10 +849,15 @@ const WorkflowContent = React.memo(() => {
const name = getUniqueBlockName(baseName, blocks)
if (containerInfo) {
// Calculate position relative to the container node
// Calculate position relative to the container's content area
// Account for header (50px), left padding (16px), and top padding (16px)
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
const relativePosition = {
x: position.x - containerInfo.loopPosition.x,
y: position.y - containerInfo.loopPosition.y,
x: position.x - containerInfo.loopPosition.x - leftPadding,
y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding,
}
// Capture existing child blocks before adding the new one
@@ -1106,8 +1062,11 @@ const WorkflowContent = React.memo(() => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
})
// If hovering over a container node, highlight it
if (containerInfo) {
// Highlight container if hovering over it and not dragging a subflow
// Subflow drag is marked by body class flag set by toolbar
const isSubflowDrag = document.body.classList.contains('sim-drag-subflow')
if (containerInfo && !isSubflowDrag) {
const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`)
if (containerElement) {
// Determine the type of container node for appropriate styling
@@ -1251,10 +1210,11 @@ const WorkflowContent = React.memo(() => {
prevBlocksRef.current = blocks
const hash = Object.values(blocks)
.map(
(b) =>
`${b.id}:${b.type}:${b.name}:${b.position.x.toFixed(0)}:${b.position.y.toFixed(0)}:${b.height}:${b.data?.parentId || ''}`
)
.map((b) => {
const width = typeof b.data?.width === 'number' ? b.data.width : ''
const height = typeof b.data?.height === 'number' ? b.data.height : ''
return `${b.id}:${b.type}:${b.name}:${b.position.x.toFixed(0)}:${b.position.y.toFixed(0)}:${b.height}:${b.data?.parentId || ''}:${width}:${height}`
})
.join('|')
prevBlocksHashRef.current = hash
@@ -1273,7 +1233,6 @@ const WorkflowContent = React.memo(() => {
// Handle container nodes differently
if (block.type === 'loop' || block.type === 'parallel') {
const hasNestedError = nestedSubflowErrors.has(block.id)
nodeArray.push({
id: block.id,
type: 'subflowNode',
@@ -1283,9 +1242,9 @@ const WorkflowContent = React.memo(() => {
dragHandle: '.workflow-drag-handle',
data: {
...block.data,
name: block.name,
width: block.data?.width || 500,
height: block.data?.height || 300,
hasNestedError,
kind: block.type === 'loop' ? 'loop' : 'parallel',
},
})
@@ -1312,7 +1271,25 @@ const WorkflowContent = React.memo(() => {
position,
parentId: block.data?.parentId,
dragHandle: '.workflow-drag-handle',
extent: block.data?.extent || undefined,
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
if (!parentId) return block.data?.extent || undefined
// Constrain ONLY the top by header height (42px) and keep a small left padding.
// Do not clamp right/bottom so blocks can move freely within the body.
const headerHeight = 42
const leftPadding = 16
const minX = leftPadding
const minY = headerHeight
const maxX = Number.POSITIVE_INFINITY
const maxY = Number.POSITIVE_INFINITY
return [
[minX, minY],
[maxX, maxY],
] as [[number, number], [number, number]]
})(),
data: {
type: block.type,
config: blockConfig, // Cached config reference
@@ -1320,22 +1297,14 @@ const WorkflowContent = React.memo(() => {
isActive,
isPending,
},
// Include dynamic dimensions for container resizing calculations
width: 350, // Standard width
// Include dynamic dimensions for container resizing calculations (must match rendered size)
width: 250, // Standard width - matches w-[250px] in workflow-block.tsx
height: Math.max(block.height || 100, 100), // Use actual height with minimum
})
})
return nodeArray
}, [
blocksHash,
blocks,
activeBlockIds,
pendingBlocks,
isDebugging,
nestedSubflowErrors,
getBlockConfig,
])
}, [blocksHash, blocks, activeBlockIds, pendingBlocks, isDebugging, getBlockConfig])
// Update nodes - use store version to avoid collaborative feedback loops
const onNodesChange = useCallback(
@@ -1353,7 +1322,10 @@ const WorkflowContent = React.memo(() => {
[nodes, storeUpdateBlockPosition]
)
// Effect to resize loops when nodes change (add/remove/position change)
/**
* Effect to resize loops when nodes change (add/remove/position change).
* Runs on every node change for immediate responsiveness.
*/
useEffect(() => {
// Skip during initial render when nodes aren't loaded yet
if (nodes.length === 0) return
@@ -1391,11 +1363,6 @@ const WorkflowContent = React.memo(() => {
})
}, [blocks, collaborativeUpdateBlockPosition, updateParentId, getNodeAbsolutePosition])
// Validate nested subflows whenever blocks change
useEffect(() => {
validateNestedSubflows()
}, [blocks, validateNestedSubflows])
// Update edges
const onEdgesChange = useCallback(
(changes: any) => {
@@ -1542,6 +1509,20 @@ const WorkflowContent = React.memo(() => {
// Get the node's absolute position to properly calculate intersections
const nodeAbsolutePos = getNodeAbsolutePosition(node.id)
// Prevent subflows from being dragged into other subflows
if (node.type === 'subflowNode') {
// Clear any highlighting for subflow nodes
if (potentialParentId) {
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
if (prevElement) {
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
}
setPotentialParentId(null)
document.body.style.cursor = ''
}
return // Exit early - subflows cannot be placed inside other subflows
}
// Find intersections with container nodes using absolute coordinates
const intersectingNodes = getNodes()
.filter((n) => {
@@ -1551,34 +1532,16 @@ const WorkflowContent = React.memo(() => {
// Skip if this container is already the parent of the node being dragged
if (n.id === currentParentId) return false
// Skip self-nesting: prevent a container from becoming its own descendant
if (node.type === 'subflowNode') {
// Get the full hierarchy of the potential parent
const hierarchy = getNodeHierarchy(n.id)
// If the dragged node is in the hierarchy, this would create a circular reference
if (hierarchy.includes(node.id)) {
return false // Avoid circular nesting
}
}
// Get the container's absolute position
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
// Get dimensions based on node type
const nodeWidth =
node.type === 'subflowNode'
? node.data?.width || 500
: node.type === 'condition'
? 250
: 350
// Get dimensions based on node type (must match actual rendered dimensions)
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
const nodeHeight =
node.type === 'subflowNode'
? node.data?.height || 300
: node.type === 'condition'
? 150
: 100
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
// Check intersection using absolute coordinates
const nodeRect = {
@@ -1626,13 +1589,6 @@ const WorkflowContent = React.memo(() => {
// Use the most appropriate container (deepest or smallest at same depth)
const bestContainerMatch = sortedContainers[0]
// Add a check to see if the bestContainerMatch is a part of the hierarchy of the node being dragged
const hierarchy = getNodeHierarchy(node.id)
if (hierarchy.includes(bestContainerMatch.container.id)) {
setPotentialParentId(null)
return
}
setPotentialParentId(bestContainerMatch.container.id)
// Add highlight class and change cursor
@@ -1670,7 +1626,6 @@ const WorkflowContent = React.memo(() => {
getNodes,
potentialParentId,
blocks,
getNodeHierarchy,
getNodeAbsolutePosition,
getNodeDepth,
collaborativeUpdateBlockPosition,
@@ -1746,31 +1701,32 @@ const WorkflowContent = React.memo(() => {
return // Exit early - don't allow starter blocks to have parents
}
// If we're dragging a container node, do additional checks to prevent circular references
// Subflow nodes cannot be placed inside other subflows
// This check is redundant with onNodeDrag but serves as a safety guard
if (node.type === 'subflowNode' && potentialParentId) {
// Get the hierarchy of the potential parent container
const parentHierarchy = getNodeHierarchy(potentialParentId)
// If the dragged node is in the parent's hierarchy, it would create a circular reference
if (parentHierarchy.includes(node.id)) {
logger.warn('Prevented circular container nesting', {
draggedNodeId: node.id,
draggedNodeType: node.type,
potentialParentId,
parentHierarchy,
})
return
}
logger.warn('Prevented subflow node from being placed inside a container', {
blockId: node.id,
attemptedParentId: potentialParentId,
})
// Reset state without updating parent
setDraggedNodeId(null)
setPotentialParentId(null)
return
}
// Update the node's parent relationship
if (potentialParentId) {
// Compute relative position BEFORE updating parent to avoid stale state
// Account for header (50px), left padding (16px), and top padding (16px)
const containerAbsPosBefore = getNodeAbsolutePosition(potentialParentId)
const nodeAbsPosBefore = getNodeAbsolutePosition(node.id)
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
const relativePositionBefore = {
x: nodeAbsPosBefore.x - containerAbsPosBefore.x,
y: nodeAbsPosBefore.y - containerAbsPosBefore.y,
x: nodeAbsPosBefore.x - containerAbsPosBefore.x - leftPadding,
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
}
// Prepare edges that will be added when moving into the container
@@ -1850,7 +1806,6 @@ const WorkflowContent = React.memo(() => {
dragStartParentId,
potentialParentId,
updateNodeParent,
getNodeHierarchy,
collaborativeUpdateBlockPosition,
addEdge,
determineSourceHandle,
@@ -2005,13 +1960,8 @@ const WorkflowContent = React.memo(() => {
edgeTypes={edgeTypes}
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
onInit={(instance) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
instance.fitView({ padding: 0.3 })
})
})
}}
fitView
fitViewOptions={{ padding: 0.6 }}
minZoom={0.1}
maxZoom={1.3}
panOnScroll

View File

@@ -250,7 +250,7 @@ export function SidebarNew() {
className='group -m-1 p-0 p-1'
>
<ChevronDown
className={`h-[8px] w-[12px] transition-transform duration-200 ${
className={`h-[8px] w-[12px] transition-transform duration-100 ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>

View File

@@ -139,6 +139,7 @@ export function WorkflowPreview({
draggable: false,
data: {
...block.data,
name: block.name,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',
@@ -159,6 +160,7 @@ export function WorkflowPreview({
draggable: false,
data: {
...block.data,
name: block.name,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',

View File

@@ -198,6 +198,75 @@ export default {
transform: 'translateX(0)',
},
},
'dash-animation': {
from: {
strokeDashoffset: '0',
},
to: {
strokeDashoffset: '-24',
},
},
'pulse-ring': {
'0%': {
boxShadow: '0 0 0 0 hsl(var(--border))',
},
'50%': {
boxShadow: '0 0 0 8px hsl(var(--border))',
},
'100%': {
boxShadow: '0 0 0 0 hsl(var(--border))',
},
},
'code-shimmer': {
'0%': {
transform: 'translateX(-100%)',
},
'100%': {
transform: 'translateX(100%)',
},
},
orbit: {
'0%': {
transform:
'rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg))',
},
'100%': {
transform:
'rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg))',
},
},
marquee: {
from: {
transform: 'translateX(0)',
},
to: {
transform: 'translateX(calc(-100% - var(--gap)))',
},
},
'marquee-vertical': {
from: {
transform: 'translateY(0)',
},
to: {
transform: 'translateY(calc(-100% - var(--gap)))',
},
},
'fade-in': {
from: {
opacity: '0',
},
to: {
opacity: '1',
},
},
'placeholder-pulse': {
'0%, 100%': {
opacity: '0.5',
},
'50%': {
opacity: '0.8',
},
},
},
animation: {
'slide-down': 'slide-down 0.3s ease-out',
@@ -212,6 +281,14 @@ export default {
'accordion-up': 'accordion-up 0.2s ease-out',
'slide-left': 'slide-left 80s linear infinite',
'slide-right': 'slide-right 80s linear infinite',
'dash-animation': 'dash-animation 1.5s linear infinite',
'pulse-ring': 'pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'code-shimmer': 'code-shimmer 1.5s infinite',
orbit: 'orbit calc(var(--duration, 2) * 1s) linear infinite',
marquee: 'marquee var(--duration) infinite linear',
'marquee-vertical': 'marquee-vertical var(--duration) linear infinite',
'fade-in': 'fade-in 0.3s ease-in-out forwards',
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
},
},
},