auth page refactor

This commit is contained in:
Saumya 2025-07-16 10:05:12 +05:30
parent 414eb23949
commit 73f919e28d
13 changed files with 2361 additions and 346 deletions

View File

@ -5,9 +5,10 @@ import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft,
Save,
Edit2,
Settings,
GitBranch,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -19,6 +20,7 @@ import { useCreateAgentWorkflow, useUpdateAgentWorkflow, useAgentWorkflows } fro
import { CreateWorkflowRequest, UpdateWorkflowRequest } from '@/hooks/react-query/agents/workflow-utils';
import { useAgentTools } from '@/hooks/react-query/agents/use-agent-tools';
import { ConditionalWorkflowBuilder, ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
import { ReactFlowWorkflowBuilder } from '@/components/workflows/react-flow/ReactFlowWorkflowBuilder';
const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
let globalOrder = 1;
@ -42,7 +44,6 @@ const convertToNestedJSON = (steps: ConditionalStep[]): any[] => {
return jsonStep;
});
};
return convertStepsWithNesting(steps);
};
@ -51,6 +52,11 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
const convertStepsFromNested = (stepList: any[]): ConditionalStep[] => {
return stepList.map((step) => {
console.log('Converting nested step:', step.name, step.type);
if (step.children && step.children.length > 0) {
console.log(` Step ${step.name} has ${step.children.length} children:`, step.children.map((c: any) => c.name));
}
const conditionalStep: ConditionalStep = {
id: step.id || Math.random().toString(36).substr(2, 9),
name: step.name,
@ -59,17 +65,23 @@ const reconstructFromNestedJSON = (nestedSteps: any[]): ConditionalStep[] => {
config: step.config || {},
order: step.order || step.step_order || 0,
enabled: step.enabled !== false,
hasIssues: step.hasIssues || false
hasIssues: step.hasIssues || false,
children: [] // Initialize children array
};
// Handle condition metadata
if (step.type === 'condition' && step.conditions) {
console.log(` Adding conditions to ${step.name}:`, step.conditions);
conditionalStep.conditions = step.conditions;
}
// Handle children - this is crucial for nested conditions
if (step.children && Array.isArray(step.children) && step.children.length > 0) {
console.log(` Converting ${step.children.length} children for ${step.name}`);
conditionalStep.children = convertStepsFromNested(step.children);
} else {
conditionalStep.children = [];
}
console.log(` Converted step ${step.name}:`, conditionalStep);
return conditionalStep;
});
};
@ -201,8 +213,17 @@ export default function WorkflowPage() {
const [triggerPhrase, setTriggerPhrase] = useState('');
const [isDefault, setIsDefault] = useState(false);
const [steps, setSteps] = useState<ConditionalStep[]>([]);
// Debug wrapper for setSteps
const setStepsWithDebug = useCallback((newSteps: ConditionalStep[]) => {
console.log('=== PARENT: setSteps called ===');
console.log('New steps:', newSteps);
console.log('New steps count:', newSteps.length);
setSteps(newSteps);
}, []);
const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(isEditing);
const [useReactFlow, setUseReactFlow] = useState(true);
useEffect(() => {
if (isEditing && workflows.length > 0) {
@ -215,15 +236,38 @@ export default function WorkflowPage() {
let treeSteps: ConditionalStep[];
try {
// Check if workflow has proper nested structure
if (workflow.steps.some((step: any) => step.children && Array.isArray(step.children))) {
const hasNestedStructure = workflow.steps.some((step: any) => step.children && Array.isArray(step.children) && step.children.length > 0);
console.log('Has nested structure:', hasNestedStructure);
console.log('First few steps to check:', workflow.steps.slice(0, 3));
if (hasNestedStructure) {
console.log('Using reconstructFromNestedJSON');
treeSteps = reconstructFromNestedJSON(workflow.steps);
} else {
console.log('Using reconstructFromFlatJSON');
treeSteps = reconstructFromFlatJSON(workflow.steps);
}
} catch (error) {
console.warn('Error reconstructing workflow steps, using fallback:', error);
treeSteps = reconstructFromFlatJSON(workflow.steps);
}
console.log('=== LOADING EXISTING WORKFLOW ===');
console.log('Raw workflow from API:', workflow);
console.log('Raw workflow steps:', workflow.steps);
console.log('Loaded workflow steps:', treeSteps);
console.log('Loaded steps count:', treeSteps.length);
// Debug: Check if steps have children
treeSteps.forEach((step, index) => {
console.log(`Step ${index}: ${step.name} (${step.type})`);
if (step.children && step.children.length > 0) {
console.log(` Has ${step.children.length} children:`, step.children.map(c => `${c.name} (${c.type})`));
} else {
console.log(' No children');
}
});
setSteps(treeSteps);
setIsLoading(false);
} else if (!isLoadingWorkflows) {
@ -239,7 +283,14 @@ export default function WorkflowPage() {
toast.error('Please enter a workflow name');
return;
}
console.log('=== SAVING WORKFLOW ===');
console.log('Steps before conversion:', steps);
console.log('Steps count:', steps.length);
const nestedSteps = convertToNestedJSON(steps);
console.log('Nested steps after conversion:', nestedSteps);
try {
if (isEditing) {
const updateRequest: UpdateWorkflowRequest = {
@ -336,6 +387,29 @@ export default function WorkflowPage() {
</Popover>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setUseReactFlow(!useReactFlow)}
className="h-8"
>
{useReactFlow ? <ToggleRight className="h-3.5 w-3.5" /> : <ToggleLeft className="h-3.5 w-3.5" />}
{useReactFlow ? 'Visual Builder' : 'List Builder'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
console.log('=== DEBUG LOG ===');
console.log('Current workflow steps:', steps);
console.log('Steps count:', steps.length);
console.log('Nested JSON:', convertToNestedJSON(steps));
}}
className="h-8"
>
Debug Log
</Button>
<Button
onClick={handleSave}
disabled={createWorkflowMutation.isPending || updateWorkflowMutation.isPending}
@ -348,17 +422,27 @@ export default function WorkflowPage() {
: 'Save Workflow'
}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto">
<div className="p-6">
<div className={useReactFlow ? "h-full" : "max-w-4xl mx-auto"}>
<div className={useReactFlow ? "h-full" : "p-6"}>
{useReactFlow ? (
<ReactFlowWorkflowBuilder
steps={steps}
onStepsChange={setStepsWithDebug}
agentTools={agentTools}
isLoadingTools={isLoadingTools}
/>
) : (
<ConditionalWorkflowBuilder
steps={steps}
onStepsChange={setSteps}
onStepsChange={setStepsWithDebug}
agentTools={agentTools}
isLoadingTools={isLoadingTools}
/>
)}
</div>
</div>
</div>

View File

@ -4,10 +4,8 @@ import Link from 'next/link';
import { SubmitButton } from '@/components/ui/submit-button';
import { Input } from '@/components/ui/input';
import GoogleSignIn from '@/components/GoogleSignIn';
import { FlickeringGrid } from '@/components/home/ui/flickering-grid';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useState, useEffect, useRef, Suspense } from 'react';
import { useScroll } from 'motion/react';
import { useState, useEffect, Suspense } from 'react';
import { signIn, signUp, forgotPassword } from './actions';
import { useSearchParams, useRouter } from 'next/navigation';
import {
@ -29,6 +27,9 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import GitHubSignIn from '@/components/GithubSignIn';
import { KortixLogo } from '@/components/sidebar/kortix-logo';
import { FlickeringGrid } from '@/components/home/ui/flickering-grid';
import { Ripple } from '@/components/ui/ripple';
function LoginContent() {
const router = useRouter();
@ -39,20 +40,15 @@ function LoginContent() {
const message = searchParams.get('message');
const isSignUp = mode === 'signup';
const tablet = useMediaQuery('(max-width: 1024px)');
const isMobile = useMediaQuery('(max-width: 768px)');
const [mounted, setMounted] = useState(false);
const [isScrolling, setIsScrolling] = useState(false);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
const { scrollY } = useScroll();
// Redirect if user is already logged in, checking isLoading state
useEffect(() => {
if (!isLoading && user) {
router.push(returnUrl || '/dashboard');
}
}, [user, isLoading, router, returnUrl]);
// Determine if message is a success message
const isSuccessMessage =
message &&
(message.includes('Check your email') ||
@ -83,30 +79,6 @@ function LoginContent() {
}
}, [isSuccessMessage]);
// Detect when scrolling is active to reduce animation complexity
useEffect(() => {
const unsubscribe = scrollY.on('change', () => {
setIsScrolling(true);
// Clear any existing timeout
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
// Set a new timeout
scrollTimeout.current = setTimeout(() => {
setIsScrolling(false);
}, 300); // Wait 300ms after scroll stops
});
return () => {
unsubscribe();
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
};
}, [scrollY]);
const handleSignIn = async (prevState: any, formData: FormData) => {
if (returnUrl) {
formData.append('returnUrl', returnUrl);
@ -222,368 +194,283 @@ function LoginContent() {
// Show loading spinner while checking auth state
if (isLoading) {
return (
<main className="flex flex-col items-center justify-center min-h-screen w-full">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</main>
<div className="min-h-screen bg-background flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
// Registration success view
if (registrationSuccess) {
return (
<main className="flex flex-col items-center justify-center min-h-screen w-full">
<div className="w-full divide-y divide-border">
<section className="w-full relative overflow-hidden">
<div className="relative flex flex-col items-center w-full px-6">
{/* Background elements from the original view */}
<div className="absolute left-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-background z-10" />
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
</div>
<div className="absolute right-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-background z-10" />
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
</div>
<div className="absolute inset-x-1/4 top-0 h-[600px] md:h-[800px] -z-20 bg-background rounded-b-xl"></div>
{/* Success content */}
<div className="relative z-10 pt-24 pb-8 max-w-xl mx-auto h-full w-full flex flex-col gap-2 items-center justify-center">
<div className="flex flex-col items-center text-center">
<div className="bg-green-50 dark:bg-green-950/20 rounded-full p-4 mb-6">
<MailCheck className="h-12 w-12 text-green-500 dark:text-green-400" />
</div>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-medium tracking-tighter text-center text-balance text-primary mb-4">
Check your email
</h1>
<p className="text-base md:text-lg text-center text-muted-foreground font-medium text-balance leading-relaxed tracking-tight max-w-md mb-2">
We've sent a confirmation link to:
</p>
<p className="text-lg font-medium mb-6">
{registrationEmail || 'your email address'}
</p>
<div className="bg-green-50 dark:bg-green-950/20 border border-green-100 dark:border-green-900/50 rounded-lg p-6 mb-8 max-w-md w-full">
<p className="text-sm text-green-800 dark:text-green-400 leading-relaxed">
Click the link in the email to activate your account. If
you don't see the email, check your spam folder.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 w-full max-w-sm">
<Link
href="/"
className="flex h-12 items-center justify-center w-full text-center rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
>
Return to home
</Link>
<button
onClick={resetRegistrationSuccess}
className="flex h-12 items-center justify-center w-full text-center rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
>
Back to sign in
</button>
</div>
</div>
</div>
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md mx-auto">
<div className="text-center">
<div className="bg-green-50 dark:bg-green-950/20 rounded-full p-4 mb-6 inline-flex">
<MailCheck className="h-12 w-12 text-green-500 dark:text-green-400" />
</div>
</section>
<h1 className="text-3xl font-semibold text-foreground mb-4">
Check your email
</h1>
<p className="text-muted-foreground mb-2">
We've sent a confirmation link to:
</p>
<p className="text-lg font-medium mb-6">
{registrationEmail || 'your email address'}
</p>
<div className="bg-green-50 dark:bg-green-950/20 border border-green-100 dark:border-green-900/50 rounded-lg p-4 mb-8">
<p className="text-sm text-green-800 dark:text-green-400">
Click the link in the email to activate your account. If you don't see the email, check your spam folder.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Link
href="/"
className="flex h-11 items-center justify-center px-6 text-center rounded-lg border border-border bg-background hover:bg-accent transition-colors"
>
Return to home
</Link>
<button
onClick={resetRegistrationSuccess}
className="flex h-11 items-center justify-center px-6 text-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Back to sign in
</button>
</div>
</div>
</div>
</main>
</div>
);
}
return (
<main className="flex flex-col items-center justify-center min-h-screen w-full">
<div className="w-full divide-y divide-border">
{/* Hero-like header with flickering grid */}
<section className="w-full relative overflow-hidden">
<div className="relative flex flex-col items-center w-full px-6">
{/* Left side flickering grid with gradient fades */}
<div className="absolute left-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
{/* Horizontal fade from left to right */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-background z-10" />
{/* Vertical fade from top */}
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
{/* Vertical fade to bottom */}
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
<div className="h-full w-full">
<FlickeringGrid
className="h-full w-full"
squareSize={mounted && tablet ? 2 : 2.5}
gridGap={mounted && tablet ? 2 : 2.5}
color="var(--secondary)"
maxOpacity={0.4}
flickerChance={isScrolling ? 0.01 : 0.03}
/>
</div>
</div>
{/* Right side flickering grid with gradient fades */}
<div className="absolute right-0 top-0 h-[600px] md:h-[800px] w-1/3 -z-10 overflow-hidden">
{/* Horizontal fade from right to left */}
<div className="absolute inset-0 bg-gradient-to-l from-transparent via-transparent to-background z-10" />
{/* Vertical fade from top */}
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-background via-background/90 to-transparent z-10" />
{/* Vertical fade to bottom */}
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-background via-background/90 to-transparent z-10" />
<div className="h-full w-full">
<FlickeringGrid
className="h-full w-full"
squareSize={mounted && tablet ? 2 : 2.5}
gridGap={mounted && tablet ? 2 : 2.5}
color="var(--secondary)"
maxOpacity={0.4}
flickerChance={isScrolling ? 0.01 : 0.03}
/>
</div>
</div>
{/* Center content background with rounded bottom */}
<div className="absolute inset-x-1/4 top-0 h-[600px] md:h-[800px] -z-20 bg-background rounded-b-xl"></div>
{/* Header content */}
<div className="relative z-10 pt-24 pb-8 max-w-md mx-auto h-full w-full flex flex-col gap-2 items-center justify-center">
<Link
href="/"
className="group border border-border/50 bg-background hover:bg-accent/20 rounded-full text-sm h-8 px-3 flex items-center gap-2 transition-all duration-200 shadow-sm mb-6"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-muted-foreground text-xs tracking-wide">
<div className="min-h-screen bg-background relative">
<div className="absolute top-6 left-6 z-10">
<Link href="/" className="flex items-center">
<KortixLogo size={28} />
</Link>
</div>
<div className="flex min-h-screen">
<div className="flex-1 flex items-center justify-center p-4 lg:p-8">
<div className="w-full max-w-sm">
<div className="mb-8">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4" />
Back to home
</span>
</Link>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-medium tracking-tighter text-center text-balance text-primary">
{isSignUp ? 'Join Suna' : 'Welcome back'}
</h1>
<p className="text-base md:text-lg text-center text-muted-foreground font-medium text-balance leading-relaxed tracking-tight mt-2 mb-6">
{isSignUp
? 'Create your account and start building with AI'
: 'Sign in to your account to continue'}
</p>
</Link>
<h1 className="text-2xl font-semibold text-foreground">
{isSignUp ? 'Create your account' : 'Log into your account'}
</h1>
</div>
{message && !isSuccessMessage && (
<div className="mb-6 p-4 rounded-lg flex items-center gap-3 bg-destructive/10 border border-destructive/20 text-destructive">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span className="text-sm">{message}</span>
</div>
)}
<div className="space-y-3 mb-6">
<GoogleSignIn returnUrl={returnUrl || undefined} />
<GitHubSignIn returnUrl={returnUrl || undefined} />
</div>
</div>
{/* Auth form card */}
<div className="relative z-10 flex justify-center px-6 pb-24">
<div className="w-full max-w-md rounded-xl bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] border border-border p-8">
{/* Non-registration related messages */}
{message && !isSuccessMessage && (
<div className="mb-6 p-4 rounded-lg flex items-center gap-3 bg-secondary/10 border border-secondary/20 text-secondary">
<AlertCircle className="h-5 w-5 flex-shrink-0 text-secondary" />
<span className="text-sm font-medium">{message}</span>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-background text-muted-foreground">
or continue with email
</span>
</div>
</div>
<form className="space-y-4">
<div>
<Input
id="email"
name="email"
type="email"
placeholder="Email address"
className="h-11 rounded-xl"
required
/>
</div>
<div>
<Input
id="password"
name="password"
type="password"
placeholder="Password"
className="h-11 rounded-xl"
required
/>
</div>
{isSignUp && (
<div>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="Confirm password"
className="h-11 rounded-xl"
required
/>
</div>
)}
{/* OAuth Sign In */}
<div className="w-full flex flex-col gap-3 mb-6">
<div className="w-full">
<GoogleSignIn returnUrl={returnUrl || undefined} />
</div>
<div className="w-full">
<GitHubSignIn returnUrl={returnUrl || undefined} />
</div>
</div>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-[#F3F4F6] dark:bg-[#F9FAFB]/[0.02] text-muted-foreground">
or continue with email
</span>
</div>
</div>
{/* Form */}
<form className="space-y-4">
<div>
<Input
id="email"
name="email"
type="email"
placeholder="Email address"
className="h-12 rounded-full bg-background border-border"
required
/>
</div>
<div>
<Input
id="password"
name="password"
type="password"
placeholder="Password"
className="h-12 rounded-full bg-background border-border"
required
/>
</div>
{isSignUp && (
<div>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="Confirm password"
className="h-12 rounded-full bg-background border-border"
required
/>
</div>
)}
<div className="space-y-4 pt-4">
{!isSignUp ? (
<>
<SubmitButton
formAction={handleSignIn}
className="w-full h-12 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
pendingText="Signing in..."
>
Sign in
</SubmitButton>
<Link
href={`/auth?mode=signup${returnUrl ? `&returnUrl=${returnUrl}` : ''}`}
className="flex h-12 items-center justify-center w-full text-center rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
>
Create new account
</Link>
</>
) : (
<>
<SubmitButton
formAction={handleSignUp}
className="w-full h-12 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
pendingText="Creating account..."
>
Sign up
</SubmitButton>
<Link
href={`/auth${returnUrl ? `?returnUrl=${returnUrl}` : ''}`}
className="flex h-12 items-center justify-center w-full text-center rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
>
Back to sign in
</Link>
</>
)}
</div>
{!isSignUp && (
<div className="text-center pt-2">
<button
type="button"
onClick={() => setForgotPasswordOpen(true)}
className="text-sm text-primary hover:underline"
<div className="space-y-3 pt-2">
{!isSignUp ? (
<>
<SubmitButton
formAction={handleSignIn}
className="w-full h-11 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
pendingText="Signing in..."
>
Forgot password?
</button>
</div>
)}
</form>
Sign in
</SubmitButton>
<div className="mt-8 text-center text-xs text-muted-foreground">
By continuing, you agree to our{' '}
<Link href="/terms" className="text-primary hover:underline">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
<Link
href={`/auth?mode=signup${returnUrl ? `&returnUrl=${returnUrl}` : ''}`}
className="text-sm flex h-11 items-center justify-center w-full text-center border border-border bg-background hover:bg-accent transition-colors rounded-xl"
>
Create new account
</Link>
</>
) : (
<>
<SubmitButton
formAction={handleSignUp}
className="w-full h-11 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
pendingText="Creating account..."
>
Sign up
</SubmitButton>
<Link
href={`/auth${returnUrl ? `?returnUrl=${returnUrl}` : ''}`}
className="flex h-11 items-center justify-center w-full text-center border border-border bg-background hover:bg-accent transition-colors rounded-md"
>
Back to sign in
</Link>
</>
)}
</div>
{!isSignUp && (
<div className="text-center pt-2">
<button
type="button"
onClick={() => setForgotPasswordOpen(true)}
className="text-sm text-primary hover:underline"
>
Forgot password?
</button>
</div>
)}
</form>
<div className="mt-8 text-center text-xs text-muted-foreground">
By continuing, you agree to our{' '}
<Link href="/legal?tab=terms" className="text-primary hover:underline">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/legal?tab=privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
</div>
</div>
</section>
</div>
<div className="hidden lg:flex flex-1 items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 relative overflow-hidden">
<div className="absolute inset-0 opacity-60">
<Ripple />
</div>
<div className="absolute inset-0">
<FlickeringGrid
className="h-full w-full mix-blend-soft-light opacity-30"
squareSize={3}
gridGap={3}
color="var(--primary)"
maxOpacity={0.6}
flickerChance={0.02}
/>
</div>
<div className="absolute inset-0">
<FlickeringGrid
className="h-full w-full mix-blend-overlay opacity-20"
squareSize={1.5}
gridGap={1.5}
color="var(--secondary)"
maxOpacity={0.3}
flickerChance={0.01}
/>
</div>
</div>
</div>
{/* Forgot Password Dialog */}
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className="sm:max-w-md rounded-xl bg-[#F3F4F6] dark:bg-[#17171A] border border-border [&>button]:hidden">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="text-xl font-medium">
Reset Password
</DialogTitle>
<DialogTitle>Reset Password</DialogTitle>
<button
onClick={() => setForgotPasswordOpen(false)}
className="rounded-full p-1 hover:bg-muted transition-colors"
className="rounded-sm p-1 hover:bg-accent transition-colors"
>
<X className="h-4 w-4 text-muted-foreground" />
<X className="h-4 w-4" />
</button>
</div>
<DialogDescription className="text-muted-foreground">
Enter your email address and we'll send you a link to reset your
password.
<DialogDescription>
Enter your email address and we'll send you a link to reset your password.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleForgotPassword} className="space-y-4 py-4">
<form onSubmit={handleForgotPassword} className="space-y-4">
<Input
id="forgot-password-email"
type="email"
placeholder="Email address"
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
className="h-12 rounded-full bg-background border-border"
className="h-11 rounded-xl"
required
/>
{forgotPasswordStatus.message && (
<div
className={`p-4 rounded-lg flex items-center gap-3 ${
className={`p-3 rounded-md flex items-center gap-3 ${
forgotPasswordStatus.success
? 'bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900/50 text-green-800 dark:text-green-400'
: 'bg-secondary/10 border border-secondary/20 text-secondary'
: 'bg-destructive/10 border border-destructive/20 text-destructive'
}`}
>
{forgotPasswordStatus.success ? (
<CheckCircle className="h-5 w-5 flex-shrink-0 text-green-500 dark:text-green-400" />
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<AlertCircle className="h-5 w-5 flex-shrink-0 text-secondary" />
<AlertCircle className="h-4 w-4 flex-shrink-0" />
)}
<span className="text-sm font-medium">
{forgotPasswordStatus.message}
</span>
<span className="text-sm">{forgotPasswordStatus.message}</span>
</div>
)}
<DialogFooter className="flex sm:justify-start gap-3 pt-2">
<button
type="submit"
className="h-12 px-6 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-all shadow-md"
>
Send Reset Link
</button>
<DialogFooter className="gap-2">
<button
type="button"
onClick={() => setForgotPasswordOpen(false)}
className="h-12 px-6 rounded-full border border-border bg-background hover:bg-accent/20 transition-all"
className="h-10 px-4 border border-border bg-background hover:bg-accent transition-colors rounded-md"
>
Cancel
</button>
<button
type="submit"
className="h-10 px-4 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors rounded-md"
>
Send Reset Link
</button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main>
</div>
);
}
@ -591,9 +478,9 @@ export default function Login() {
return (
<Suspense
fallback={
<main className="flex flex-col items-center justify-center min-h-screen w-full">
<div className="w-12 h-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
</main>
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
}
>
<LoginContent />

View File

@ -0,0 +1,69 @@
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
} from '@xyflow/react';
import useEdgeClick from '../hooks/useEdgeClick';
export default function WorkflowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
label,
labelStyle,
labelBgStyle,
}: EdgeProps) {
const onClick = useEdgeClick(id);
const [edgePath, edgeCenterX, edgeCenterY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 8, // Add some rounding to the perpendicular bends
});
return (
<>
<BaseEdge id={id} style={style} path={edgePath} markerEnd={markerEnd} />
<EdgeLabelRenderer>
{label && (
<div
style={{
position: 'absolute',
transform: `translate(${sourceX + 50}px, ${sourceY + 30}px) translate(-50%, -50%)`,
background: labelBgStyle?.fill || '#fff',
padding: '2px 6px',
borderRadius: '3px',
fontSize: labelStyle?.fontSize || 12,
color: labelStyle?.fill || '#666',
pointerEvents: 'none',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
}}
>
{label}
</div>
)}
<button
style={{
transform: `translate(${edgeCenterX}px, ${edgeCenterY}px) translate(-50%, -50%)`,
}}
onClick={onClick}
className="edge-button nodrag nopan"
>
+
</button>
</EdgeLabelRenderer>
</>
);
}

View File

@ -0,0 +1,7 @@
import WorkflowEdge from './WorkflowEdge';
const edgeTypes = {
workflow: WorkflowEdge,
};
export default edgeTypes;

View File

@ -0,0 +1,169 @@
import { memo, useState } from 'react';
import { Handle, Position } from '@xyflow/react';
import { GitBranch, MoreHorizontal, Edit2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Trash2 } from 'lucide-react';
import { getConditionLabel } from '../utils';
import { useReactFlow } from '@xyflow/react';
const ConditionNode = ({ id, data, selected }: any) => {
const { setNodes } = useReactFlow();
const [isEditOpen, setIsEditOpen] = useState(false);
const [editData, setEditData] = useState({
conditionType: data.conditionType || 'if',
expression: data.expression || '',
});
const handleSave = () => {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id === id) {
return {
...node,
data: {
...node.data,
conditionType: editData.conditionType,
expression: editData.expression,
},
};
}
return node;
})
);
setIsEditOpen(false);
};
return (
<div
className={`react-flow__node-condition group ${selected ? 'selected' : ''}`}
>
<Handle type="target" position={Position.Top} isConnectable={false} />
<div className="condition-node-content flex-col relative">
<div className="flex items-center gap-2">
<GitBranch className="h-3 w-3" />
<span className="text-xs font-medium">{getConditionLabel(data.conditionType)}</span>
</div>
{data.expression && data.conditionType !== 'else' && (
<div className="text-[10px] mt-0.5 text-amber-800 truncate max-w-[100px]" title={data.expression}>
{data.expression}
</div>
)}
<div className="absolute -top-8 right-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-white rounded shadow-sm p-1">
<Popover open={isEditOpen} onOpenChange={setIsEditOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => e.stopPropagation()}
>
<Edit2 className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end" onClick={(e) => e.stopPropagation()}>
<div className="space-y-4">
<div>
<h3 className="font-medium text-sm mb-3">Edit Condition</h3>
</div>
<div className="space-y-2">
<Label className="text-xs">Condition Type</Label>
<Select
value={editData.conditionType}
onValueChange={(value) => setEditData({ ...editData, conditionType: value })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="if">If</SelectItem>
<SelectItem value="elseif">Else If</SelectItem>
<SelectItem value="else">Else</SelectItem>
</SelectContent>
</Select>
</div>
{editData.conditionType !== 'else' && (
<div className="space-y-2">
<Label className="text-xs">Expression</Label>
<Input
value={editData.expression}
onChange={(e) => setEditData({ ...editData, expression: e.target.value })}
placeholder="e.g., user asks about pricing"
className="h-8"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditData({
conditionType: data.conditionType || 'if',
expression: data.expression || '',
});
setIsEditOpen(false);
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
>
Save
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="end">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
data.onDelete(id);
}}
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete condition
</Button>
</PopoverContent>
</Popover>
</div>
</div>
<Handle type="source" position={Position.Bottom} isConnectable={false} />
</div>
);
};
export default memo(ConditionNode);

View File

@ -0,0 +1,309 @@
import { memo, useState } from 'react';
import { Handle, Position } from '@xyflow/react';
import { AlertTriangle, MoreHorizontal, Edit2, Check, ChevronsUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useReactFlow } from '@xyflow/react';
const normalizeToolName = (toolName: string, toolType: 'agentpress' | 'mcp') => {
if (toolType === 'agentpress') {
const agentPressMapping: Record<string, string> = {
'sb_shell_tool': 'Shell Tool',
'sb_files_tool': 'Files Tool',
'sb_browser_tool': 'Browser Tool',
'sb_deploy_tool': 'Deploy Tool',
'sb_expose_tool': 'Expose Tool',
'web_search_tool': 'Web Search',
'sb_vision_tool': 'Vision Tool',
'data_providers_tool': 'Data Providers',
};
return agentPressMapping[toolName] || toolName;
} else {
return toolName
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
};
const StepNode = ({ id, data, selected }: any) => {
const { setNodes } = useReactFlow();
const [isEditOpen, setIsEditOpen] = useState(false);
const [isToolSelectOpen, setIsToolSelectOpen] = useState(false);
const [editData, setEditData] = useState({
name: data.name || '',
description: data.description || '',
tool: data.tool || '',
});
const handleSave = () => {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id === id) {
return {
...node,
data: {
...node.data,
name: editData.name,
description: editData.description,
tool: editData.tool,
hasIssues: !editData.name || editData.name === 'New Step',
},
};
}
return node;
})
);
setIsEditOpen(false);
};
const getDisplayToolName = (toolName: string) => {
if (!toolName) return null;
if (data.agentTools) {
const agentpressTool = data.agentTools.agentpress_tools?.find((t: any) => t.name === toolName);
if (agentpressTool) {
return normalizeToolName(agentpressTool.name, 'agentpress');
}
const mcpTool = data.agentTools.mcp_tools?.find((t: any) =>
`${t.server}:${t.name}` === toolName || t.name === toolName
);
if (mcpTool) {
return normalizeToolName(mcpTool.name, 'mcp');
}
}
return toolName;
};
return (
<div
className={`react-flow__node-step ${selected ? 'selected' : ''}`}
>
<Handle type="target" position={Position.Top} isConnectable={false} />
<div className="workflow-node-content">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
{data.hasIssues && (
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
)}
<h3 className="font-medium text-sm">
{data.name} {data.stepNumber ? data.stepNumber : ''}
</h3>
</div>
{data.description && (
<p className="text-xs text-muted-foreground mt-1">{data.description}</p>
)}
{data.tool && (
<div className="text-xs text-primary mt-2">
🔧 {getDisplayToolName(data.tool)}
</div>
)}
</div>
<div className="flex items-center gap-1">
<Popover open={isEditOpen} onOpenChange={setIsEditOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => e.stopPropagation()}
>
<Edit2 className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end" onClick={(e) => e.stopPropagation()}>
<div className="space-y-4">
<div>
<h3 className="font-medium text-sm mb-3">Edit Step</h3>
</div>
<div className="space-y-2">
<Label className="text-xs">Name</Label>
<Input
value={editData.name}
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
placeholder="Step name"
className="h-8"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Description</Label>
<Textarea
value={editData.description}
onChange={(e) => setEditData({ ...editData, description: e.target.value })}
placeholder="Step description (optional)"
rows={2}
className="resize-none text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Tool (optional)</Label>
<Popover open={isToolSelectOpen} onOpenChange={setIsToolSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isToolSelectOpen}
className="w-full justify-between h-8 text-sm"
>
{editData.tool ? (
<span className="text-sm">{getDisplayToolName(editData.tool)}</span>
) : (
<span className="text-muted-foreground">Select tool</span>
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tools..." className="h-8" />
<CommandEmpty>No tools found.</CommandEmpty>
<CommandList>
{data.isLoadingTools ? (
<CommandItem disabled>Loading tools...</CommandItem>
) : data.agentTools ? (
<>
{data.agentTools.agentpress_tools?.filter((tool: any) => tool.enabled).length > 0 && (
<CommandGroup heading="Default Tools">
{data.agentTools.agentpress_tools.filter((tool: any) => tool.enabled).map((tool: any) => (
<CommandItem
key={tool.name}
value={`${normalizeToolName(tool.name, 'agentpress')} ${tool.name}`}
onSelect={() => {
setEditData({ ...editData, tool: tool.name });
setIsToolSelectOpen(false);
}}
>
<div className="flex items-center gap-2">
<span>{tool.icon || '🔧'}</span>
<span>{normalizeToolName(tool.name, 'agentpress')}</span>
</div>
<Check
className={cn(
"ml-auto h-3 w-3",
editData.tool === tool.name ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
{data.agentTools.mcp_tools?.length > 0 && (
<CommandGroup heading="External Tools">
{data.agentTools.mcp_tools.map((tool: any) => (
<CommandItem
key={`${tool.server || 'default'}-${tool.name}`}
value={`${normalizeToolName(tool.name, 'mcp')} ${tool.name} ${tool.server || ''}`}
onSelect={() => {
setEditData({ ...editData, tool: tool.server ? `${tool.server}:${tool.name}` : tool.name });
setIsToolSelectOpen(false);
}}
>
<div className="flex items-center gap-2">
<span>{tool.icon || '🔧'}</span>
<span>{normalizeToolName(tool.name, 'mcp')}</span>
</div>
<Check
className={cn(
"ml-auto h-3 w-3",
editData.tool === (tool.server ? `${tool.server}:${tool.name}` : tool.name) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
</>
) : (
<CommandItem disabled>No tools available</CommandItem>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditData({
name: data.name || '',
description: data.description || '',
tool: data.tool || '',
});
setIsEditOpen(false);
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
>
Save
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="end">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
data.onDelete(id);
}}
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete step
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
<Handle type="source" position={Position.Bottom} isConnectable={false} />
</div>
);
};
export default memo(StepNode);

View File

@ -0,0 +1,9 @@
import StepNode from './StepNode';
import ConditionNode from './ConditionNode';
const nodeTypes = {
step: StepNode,
condition: ConditionNode,
};
export default nodeTypes;

View File

@ -0,0 +1,407 @@
'use client';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import {
ReactFlow,
Background,
Edge,
Node,
ProOptions,
ReactFlowProvider,
useNodesState,
useEdgesState,
addEdge,
Connection,
Controls,
Panel,
BackgroundVariant,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import './index.css';
import useLayout from './hooks/useLayout';
import nodeTypes from './NodeTypes';
import edgeTypes from './EdgeTypes';
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
import { uuid } from './utils';
import { convertWorkflowToReactFlow, convertReactFlowToWorkflow } from './utils/conversion';
import { Button } from '@/components/ui/button';
import { Plus, GitBranch } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
const proOptions: ProOptions = { account: 'paid-pro', hideAttribution: true };
interface ReactFlowWorkflowBuilderProps {
steps: ConditionalStep[];
onStepsChange: (steps: ConditionalStep[]) => void;
agentTools?: {
agentpress_tools: Array<{ name: string; description: string; icon?: string; enabled: boolean }>;
mcp_tools: Array<{ name: string; description: string; icon?: string; server?: string }>;
};
isLoadingTools?: boolean;
}
function ReactFlowWorkflowBuilderInner({
steps,
onStepsChange,
agentTools,
isLoadingTools
}: ReactFlowWorkflowBuilderProps) {
// This hook call ensures that the layout is re-calculated every time the graph changes
useLayout();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// Debug wrapper for node changes
const onNodesChangeDebug = useCallback((changes: any) => {
console.log('=== NODES CHANGED ===');
console.log('Node changes:', changes);
onNodesChange(changes);
}, [onNodesChange]);
// Debug wrapper for edge changes
const onEdgesChangeDebug = useCallback((changes: any) => {
console.log('=== EDGES CHANGED ===');
console.log('Edge changes:', changes);
onEdgesChange(changes);
}, [onEdgesChange]);
const [isInternalUpdate, setIsInternalUpdate] = useState(false);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const lastConvertedSteps = useRef<string>('');
// Debug: Monitor nodes/edges state changes
useEffect(() => {
console.log('=== NODES STATE CHANGED ===');
console.log('Current nodes count:', nodes.length);
console.log('Current nodes:', nodes.map(n => ({ id: n.id, name: n.data.name })));
}, [nodes]);
useEffect(() => {
console.log('=== EDGES STATE CHANGED ===');
console.log('Current edges count:', edges.length);
console.log('Current edges:', edges.map(e => ({ id: e.id, source: e.source, target: e.target })));
}, [edges]);
// Sync React Flow state back to parent
useEffect(() => {
console.log('=== SYNC USEEFFECT TRIGGERED ===');
console.log('isInternalUpdate:', isInternalUpdate);
console.log('nodes.length:', nodes.length);
if (!isInternalUpdate && nodes.length > 0) {
console.log('=== SYNC: Converting React Flow to workflow ===');
const convertedSteps = convertReactFlowToWorkflow(nodes, edges);
const convertedStepsStr = JSON.stringify(convertedSteps);
console.log('Converted steps:', convertedSteps);
console.log('Previous steps hash:', lastConvertedSteps.current);
console.log('Current steps hash:', convertedStepsStr);
if (convertedStepsStr !== lastConvertedSteps.current) {
console.log('Steps changed, updating parent');
console.log('Calling onStepsChange with:', convertedSteps);
lastConvertedSteps.current = convertedStepsStr;
onStepsChange(convertedSteps);
} else {
console.log('Steps unchanged, skipping update');
}
} else {
console.log('Skipping sync - isInternalUpdate:', isInternalUpdate, 'nodes.length:', nodes.length);
}
}, [nodes, edges, isInternalUpdate, onStepsChange]);
// Initialize from steps or with default
useEffect(() => {
console.log('=== INITIALIZATION ===');
console.log('Steps from parent:', steps);
console.log('Steps count:', steps.length);
setIsInternalUpdate(true);
if (steps.length > 0) {
console.log('Converting existing steps to React Flow format');
// Convert existing steps to React Flow format
const { nodes: convertedNodes, edges: convertedEdges } = convertWorkflowToReactFlow(steps);
console.log('Converted nodes:', convertedNodes);
console.log('Converted edges:', convertedEdges);
// Add agentTools and handlers to each node
const nodesWithTools = convertedNodes.map(node => ({
...node,
data: {
...node.data,
onDelete: handleNodeDelete,
agentTools,
isLoadingTools,
}
}));
setNodes(nodesWithTools);
setEdges(convertedEdges);
} else if (nodes.length === 0) {
console.log('No steps from parent, creating default node');
// Initialize with default node
const defaultNodes: Node[] = [
{
id: '1',
data: {
name: 'Start',
description: 'Click to add steps or use the Add Node button',
onDelete: handleNodeDelete,
agentTools,
isLoadingTools,
},
position: { x: 0, y: 0 },
type: 'step',
},
];
setNodes(defaultNodes);
}
// Reset flag after state updates
setTimeout(() => {
console.log('Resetting isInternalUpdate flag');
setIsInternalUpdate(false);
}, 100);
}, []); // Only run on mount
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
const handleNodeDelete = useCallback((nodeId: string) => {
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
}, [setNodes, setEdges]);
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
setSelectedNode(node.id);
}, []);
const handlePaneClick = useCallback(() => {
setSelectedNode(null);
}, []);
const addNewStep = useCallback(() => {
console.log('=== ADDING NEW STEP ===');
const newNodeId = uuid();
// Calculate position based on existing nodes
const existingNodes = nodes;
let position = { x: 0, y: 0 };
let sourceNode = null;
if (selectedNode) {
// Add after selected node
sourceNode = nodes.find(n => n.id === selectedNode);
if (sourceNode) {
position = { x: sourceNode.position.x, y: sourceNode.position.y + 150 };
}
} else if (existingNodes.length > 0) {
// Find the bottommost node
const bottomNode = existingNodes.reduce((prev, current) =>
prev.position.y > current.position.y ? prev : current
);
sourceNode = bottomNode;
position = { x: bottomNode.position.x, y: bottomNode.position.y + 150 };
}
const newNode: Node = {
id: newNodeId,
type: 'step',
position,
data: {
name: 'New Step',
description: '',
hasIssues: true,
onDelete: handleNodeDelete,
agentTools,
isLoadingTools,
},
};
console.log('Adding new node:', newNode);
console.log('Current nodes count:', nodes.length);
console.log('Source node:', sourceNode);
setNodes((nds) => {
console.log('Setting nodes - before:', nds.length);
const newNodes = [...nds, newNode];
console.log('Setting nodes - after:', newNodes.length);
return newNodes;
});
// Connect to source node if exists
if (sourceNode) {
const newEdge = {
id: `${sourceNode.id}->${newNodeId}`,
source: sourceNode.id,
target: newNodeId,
type: 'workflow',
};
console.log('Adding new edge:', newEdge);
setEdges((eds) => {
console.log('Setting edges - before:', eds.length);
const newEdges = [...eds, newEdge];
console.log('Setting edges - after:', newEdges.length);
return newEdges;
});
}
}, [nodes, selectedNode, handleNodeDelete, setNodes, setEdges, agentTools, isLoadingTools]);
const addConditionBranch = useCallback((type: 'if' | 'if-else' | 'if-elseif-else') => {
if (!selectedNode) {
toast.error('Please select a node first to add conditions');
return;
}
const sourceNode = nodes.find(n => n.id === selectedNode);
if (!sourceNode) return;
const conditions: Array<{ type: 'if' | 'elseif' | 'else'; id: string }> = [];
// Create condition nodes based on type
if (type === 'if') {
conditions.push({ type: 'if', id: uuid() });
} else if (type === 'if-else') {
conditions.push({ type: 'if', id: uuid() });
conditions.push({ type: 'else', id: uuid() });
} else {
conditions.push({ type: 'if', id: uuid() });
conditions.push({ type: 'elseif', id: uuid() });
conditions.push({ type: 'else', id: uuid() });
}
const newNodes: Node[] = [];
const newEdges: Edge[] = [];
const xSpacing = 300;
const startX = sourceNode.position.x - ((conditions.length - 1) * xSpacing / 2);
// Create condition nodes
conditions.forEach((condition, index) => {
const conditionNode: Node = {
id: condition.id,
type: 'condition',
position: {
x: startX + index * xSpacing,
y: sourceNode.position.y + 150,
},
data: {
conditionType: condition.type,
expression: condition.type !== 'else' ? '' : undefined,
onDelete: handleNodeDelete,
},
};
newNodes.push(conditionNode);
// Connect source to condition
newEdges.push({
id: `${sourceNode.id}->${condition.id}`,
source: sourceNode.id,
target: condition.id,
type: 'workflow',
label: condition.type === 'if' ? 'if' :
condition.type === 'elseif' ? 'else if' : 'else',
labelStyle: { fill: '#666', fontSize: 12 },
labelBgStyle: { fill: '#fff' },
});
});
// Add all new nodes and edges
setNodes((nds) => [...nds, ...newNodes]);
setEdges((eds) => [...eds, ...newEdges]);
}, [selectedNode, nodes, handleNodeDelete, setNodes, setEdges]);
return (
<div className="h-full w-full border rounded-none border-none bg-muted/10 react-flow-container">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeDebug}
onEdgesChange={onEdgesChangeDebug}
onConnect={onConnect}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
proOptions={proOptions}
fitView
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitViewOptions={{ padding: 0.2 }}
minZoom={0.5}
maxZoom={1.5}
nodesDraggable={false}
nodesConnectable={false}
zoomOnDoubleClick={false}
deleteKeyCode={null}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls />
<Panel position="top-left" className="react-flow__panel">
<div className="workflow-panel-actions">
<Button
variant="outline"
size="sm"
onClick={addNewStep}
>
<Plus className="h-4 w-4" />
Add Step
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<GitBranch className="h-4 w-4" />
Add Condition
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => addConditionBranch('if')}>
<span className="text-xs font-mono mr-2">if</span>
Single condition
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addConditionBranch('if-else')}>
<span className="text-xs font-mono mr-2">if-else</span>
Two branches
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addConditionBranch('if-elseif-else')}>
<span className="text-xs font-mono mr-2">if-elif-else</span>
Three branches
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{selectedNode && (
<div className="workflow-panel-selection">
Node selected: {nodes.find(n => n.id === selectedNode)?.data.name || selectedNode}
</div>
)}
</Panel>
</ReactFlow>
</div>
);
}
export function ReactFlowWorkflowBuilder(props: ReactFlowWorkflowBuilderProps) {
return (
<ReactFlowProvider>
<ReactFlowWorkflowBuilderInner {...props} />
</ReactFlowProvider>
);
}

View File

@ -0,0 +1,85 @@
import { EdgeProps, useReactFlow } from '@xyflow/react';
import { uuid } from '../utils';
// This hook implements the logic for clicking the button on a workflow edge
// On edge click: create a node in between the two nodes that are connected by the edge
function useEdgeClick(id: EdgeProps['id']) {
const { setEdges, setNodes, getNode, getEdge } = useReactFlow();
const handleEdgeClick = () => {
// First retrieve the edge object to get the source and target id
const edge = getEdge(id);
if (!edge) {
return;
}
// Retrieve the target node to get its position
const targetNode = getNode(edge.target);
if (!targetNode) {
return;
}
// Create a unique id for newly added elements
const insertNodeId = uuid();
// Get source node to access agentTools
const sourceNode = getNode(edge.source);
// This is the node object that will be added in between source and target node
const insertNode = {
id: insertNodeId,
// Place the node at the current position of the target (prevents jumping)
position: { x: targetNode.position.x, y: targetNode.position.y },
data: {
name: 'New Step',
description: '',
hasIssues: true,
onDelete: () => {},
// Pass through agentTools from source node
agentTools: sourceNode?.data.agentTools,
isLoadingTools: sourceNode?.data.isLoadingTools,
},
type: 'step',
};
// New connection from source to new node
const sourceEdge = {
id: `${edge.source}->${insertNodeId}`,
source: edge.source,
target: insertNodeId,
type: 'workflow',
};
// New connection from new node to target
const targetEdge = {
id: `${insertNodeId}->${edge.target}`,
source: insertNodeId,
target: edge.target,
type: 'workflow',
};
// Remove the edge that was clicked as we have a new connection with a node inbetween
setEdges((edges) =>
edges.filter((e) => e.id !== id).concat([sourceEdge, targetEdge])
);
// Insert the node between the source and target node in the react flow state
setNodes((nodes) => {
const targetNodeIndex = nodes.findIndex(
(node) => node.id === edge.target
);
return [
...nodes.slice(0, targetNodeIndex),
insertNode,
...nodes.slice(targetNodeIndex, nodes.length),
];
});
};
return handleEdgeClick;
}
export default useEdgeClick;

View File

@ -0,0 +1,173 @@
import { useEffect, useRef } from 'react';
import {
useReactFlow,
useStore,
Node,
Edge,
ReactFlowState,
} from '@xyflow/react';
// Simple vertical layout function with better handling of dynamic nodes
function layoutNodes(nodes: Node[], edges: Edge[]): Node[] {
if (nodes.length === 0) {
return [];
}
// Create adjacency lists for both directions
const children: Map<string, string[]> = new Map();
const parents: Map<string, string[]> = new Map();
// Initialize
nodes.forEach(node => {
children.set(node.id, []);
parents.set(node.id, []);
});
// Build graph
edges.forEach(edge => {
const childList = children.get(edge.source) || [];
childList.push(edge.target);
children.set(edge.source, childList);
const parentList = parents.get(edge.target) || [];
parentList.push(edge.source);
parents.set(edge.target, parentList);
});
// Find root nodes (nodes with no parents)
const roots = nodes.filter(node => (parents.get(node.id) || []).length === 0);
// If no roots, use first node
if (roots.length === 0 && nodes.length > 0) {
roots.push(nodes[0]);
}
// Calculate levels using BFS
const levels: Map<string, number> = new Map();
const visited: Set<string> = new Set();
const queue: Array<{id: string, level: number}> = [];
// Start from all roots
roots.forEach(root => {
queue.push({id: root.id, level: 0});
visited.add(root.id);
levels.set(root.id, 0);
});
// BFS to assign levels
while (queue.length > 0) {
const {id, level} = queue.shift()!;
const nodeChildren = children.get(id) || [];
nodeChildren.forEach(childId => {
if (!visited.has(childId)) {
visited.add(childId);
levels.set(childId, level + 1);
queue.push({id: childId, level: level + 1});
} else {
// Update level if we found a longer path
const currentLevel = levels.get(childId) || 0;
if (level + 1 > currentLevel) {
levels.set(childId, level + 1);
}
}
});
}
// Group nodes by level
const nodesByLevel: Map<number, Node[]> = new Map();
let maxLevel = 0;
nodes.forEach(node => {
const level = levels.get(node.id) || 0;
maxLevel = Math.max(maxLevel, level);
const nodesAtLevel = nodesByLevel.get(level) || [];
nodesAtLevel.push(node);
nodesByLevel.set(level, nodesAtLevel);
});
// Position nodes
const xSpacing = 200; // Reduced from 250 to 180 for tighter horizontal spacing
const ySpacing = 120; // Reduced from 150 to 120 for tighter vertical spacing
const layoutedNodes: Node[] = [];
// Calculate positions level by level
for (let level = 0; level <= maxLevel; level++) {
const nodesAtLevel = nodesByLevel.get(level) || [];
if (nodesAtLevel.length > 0) {
// Sort nodes at this level by their parent's x position for better alignment
if (level > 0) {
nodesAtLevel.sort((a, b) => {
const aParents = parents.get(a.id) || [];
const bParents = parents.get(b.id) || [];
if (aParents.length > 0 && bParents.length > 0) {
const aParentX = layoutedNodes.find(n => n.id === aParents[0])?.position.x || 0;
const bParentX = layoutedNodes.find(n => n.id === bParents[0])?.position.x || 0;
return aParentX - bParentX;
}
return 0;
});
}
// Calculate x positions
const totalWidth = (nodesAtLevel.length - 1) * xSpacing;
const startX = -totalWidth / 2;
nodesAtLevel.forEach((node, index) => {
layoutedNodes.push({
...node,
position: {
x: startX + index * xSpacing,
y: level * ySpacing
}
});
});
}
}
return layoutedNodes;
}
// This is the store selector that is used for triggering the layout
const nodeCountSelector = (state: ReactFlowState) => state.nodeLookup.size;
function useLayout() {
const initial = useRef(true);
const nodeCount = useStore(nodeCountSelector);
const { getNodes, setNodes, getEdges, fitView } = useReactFlow();
const previousNodeCount = useRef(nodeCount);
useEffect(() => {
const nodes = getNodes();
const edges = getEdges();
// Only re-layout if nodes were added/removed or on initial load
if (nodeCount !== previousNodeCount.current || initial.current) {
// Run the layout
const layoutedNodes = layoutNodes(nodes, edges);
// Update node positions
setNodes(layoutedNodes);
// Fit view
setTimeout(() => {
fitView({
duration: 200,
padding: 0.2,
maxZoom: 1.2,
});
}, 50);
if (initial.current) {
initial.current = false;
}
previousNodeCount.current = nodeCount;
}
}, [nodeCount, getEdges, getNodes, setNodes, fitView]);
}
export default useLayout;

View File

@ -0,0 +1,386 @@
/* React Flow Workflow Builder Styles */
.react-flow-container {
background: hsl(var(--background));
}
.react-flow__handle {
visibility: hidden;
}
.react-flow__node {
width: 240px;
padding: 0;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #ffffff !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.react-flow__node:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.dark .react-flow__node {
border: 1px solid #374151;
background: #1f2937 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.dark .react-flow__node:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.react-flow__node-step {
border: 1px solid #e5e7eb;
background: #ffffff !important;
}
.dark .react-flow__node-step {
border: 1px solid #374151;
background: #1f2937 !important;
}
.react-flow__node-step.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.react-flow__node-condition {
width: 140px;
height: 80px;
border: 1px solid #f59e0b;
background: rgba(245, 158, 11, 0.1) !important;
border-radius: 24px;
transition: all 0.2s ease;
}
.react-flow__node-condition:hover {
background: rgba(245, 158, 11, 0.15) !important;
}
.react-flow__node-condition.selected {
border-color: #f59e0b;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2);
}
.dark .react-flow__node-condition {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.15) !important;
}
.dark .react-flow__node-condition:hover {
background: rgba(245, 158, 11, 0.2) !important;
}
.react-flow__edge-workflow {
stroke: hsl(var(--muted-foreground));
stroke-width: 2;
transition: stroke 0.2s ease;
}
.react-flow__edge-workflow:hover {
stroke: hsl(var(--foreground));
stroke-width: 3;
}
.react-flow__edge-workflow.selected {
stroke: hsl(var(--primary));
stroke-width: 3;
}
/* Arrow marker styling */
.react-flow__arrowhead {
fill: hsl(var(--muted-foreground));
transition: fill 0.2s ease;
}
.react-flow__edge-workflow:hover .react-flow__arrowhead {
fill: hsl(var(--foreground));
}
.react-flow__edge-workflow.selected .react-flow__arrowhead {
fill: hsl(var(--primary));
}
.edge-button {
pointer-events: all;
cursor: pointer;
position: absolute;
background: #ffffff;
border: 1px solid #e5e7eb;
width: 24px;
height: 24px;
border-radius: 50%;
color: #6b7280;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.edge-button:hover {
background: #f3f4f6;
border-color: #3b82f6;
color: #3b82f6;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: scale(1.1);
}
.dark .edge-button {
background: #1f2937;
border-color: #374151;
color: #9ca3af;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.dark .edge-button:hover {
background: #374151;
border-color: #3b82f6;
color: #3b82f6;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
/* Node content styles */
.workflow-node-content {
padding: 16px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.workflow-node-header {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 4px;
}
.workflow-node-icon {
width: 20px;
height: 20px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
flex-shrink: 0;
margin-top: 2px;
}
.workflow-node-title {
font-size: 14px;
font-weight: 600;
color: #111827;
line-height: 1.2;
flex: 1;
margin: 0;
}
.dark .workflow-node-title {
color: #f9fafb;
}
.workflow-node-description {
font-size: 12px;
color: #6b7280;
line-height: 1.3;
margin: 0;
}
.dark .workflow-node-description {
color: #9ca3af;
}
.workflow-node-tool {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 6px;
font-size: 11px;
color: #475569;
font-weight: 500;
margin-top: 4px;
}
.dark .workflow-node-tool {
background: #374151;
color: #d1d5db;
}
.workflow-node-tool-icon {
width: 12px;
height: 12px;
opacity: 0.8;
}
.workflow-node-actions {
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
.react-flow__node:hover .workflow-node-actions {
opacity: 1;
}
.workflow-node-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #6b7280;
margin-top: 4px;
}
.dark .workflow-node-status {
color: #9ca3af;
}
.workflow-node-status.has-issues {
color: #dc2626;
}
.workflow-node-status.completed {
color: #16a34a;
}
.condition-node-content {
padding: 12px;
width: 100%;
height: 100%;
font-size: 12px;
font-weight: 600;
color: #d97706;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 4px;
}
.condition-node-icon {
width: 16px;
height: 16px;
margin-bottom: 2px;
}
.condition-node-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.condition-node-expression {
font-size: 9px;
font-weight: 400;
opacity: 0.8;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dark .condition-node-content {
color: #f59e0b;
}
/* Panel styling */
.react-flow__panel {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: 12px;
}
.dark .react-flow__panel {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.workflow-panel-actions {
display: flex;
gap: 8px;
align-items: center;
}
.workflow-panel-selection {
margin-top: 8px;
padding: 8px;
background: hsl(var(--primary) / 0.1);
border-radius: 8px;
font-size: 12px;
color: hsl(var(--primary));
font-weight: 500;
}
/* Background styling */
.react-flow__background {
background: hsl(var(--muted) / 0.3);
}
.dark .react-flow__background {
background: hsl(var(--muted) / 0.2);
}
/* Controls styling */
.react-flow__controls {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .react-flow__controls {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.react-flow__controls-button {
background: hsl(var(--card));
border: none;
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.react-flow__controls-button:hover {
background: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
/* Animations */
@keyframes pulse-primary {
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.4); }
50% { box-shadow: 0 0 0 8px hsl(var(--primary) / 0); }
}
.workflow-node-pulse {
animation: pulse-primary 2s infinite;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.react-flow__node {
width: 200px;
}
.react-flow__node-condition {
width: 120px;
height: 70px;
}
.workflow-node-content {
padding: 12px;
}
}

View File

@ -0,0 +1,19 @@
export const uuid = (): string =>
new Date().getTime().toString(36) + Math.random().toString(36).slice(2);
export const getStepLabel = (stepNumber: number): string => {
return `Step ${stepNumber}`;
};
export const getConditionLabel = (conditionType: 'if' | 'elseif' | 'else'): string => {
switch (conditionType) {
case 'if':
return 'If';
case 'elseif':
return 'Else If';
case 'else':
return 'Otherwise';
default:
return 'Condition';
}
};

View File

@ -0,0 +1,411 @@
import { Node, Edge } from '@xyflow/react';
import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder';
import { uuid } from '../utils';
interface ConversionResult {
nodes: Node[];
edges: Edge[];
}
export function convertWorkflowToReactFlow(steps: ConditionalStep[]): ConversionResult {
const nodes: Node[] = [];
const edges: Edge[] = [];
console.log('=== convertWorkflowToReactFlow ===');
console.log('Input steps:', steps);
console.log('Steps count:', steps.length);
if (steps.length === 0) {
return { nodes, edges };
}
// Helper function to process steps recursively
const processSteps = (
stepList: ConditionalStep[],
parentId: string | null = null,
yOffset: number = 0,
xOffset: number = 0
): { nodes: Node[]; edges: Edge[]; lastNodes: string[]; maxY: number } => {
console.log(`Processing ${stepList.length} steps at Y:${yOffset}, parent: ${parentId}`);
stepList.forEach((step, index) => {
console.log(` Step ${index}: ${step.name} (${step.type}) - ${step.children?.length || 0} children`);
});
let previousNodeId = parentId;
let currentY = yOffset;
let lastNodes: string[] = [];
const localNodes: Node[] = [];
const localEdges: Edge[] = [];
for (let i = 0; i < stepList.length; i++) {
const step = stepList[i];
// Check if this is the start of a condition group
if (step.type === 'condition') {
// Collect all consecutive conditions (if/elseif/else group)
const conditionGroup: ConditionalStep[] = [];
let j = i;
while (j < stepList.length && stepList[j].type === 'condition') {
conditionGroup.push(stepList[j]);
j++;
}
// Process the condition group as branches
const branchResult = processConditionBranches(
conditionGroup,
previousNodeId,
currentY,
xOffset
);
localNodes.push(...branchResult.nodes);
localEdges.push(...branchResult.edges);
nodes.push(...branchResult.nodes);
edges.push(...branchResult.edges);
// Update tracking
currentY = branchResult.maxY;
lastNodes = branchResult.lastNodes;
// Skip the conditions we just processed
i = j - 1;
} else {
// Regular step node
const nodeId = step.id || uuid();
const node: Node = {
id: nodeId,
type: 'step',
position: { x: xOffset, y: currentY },
data: {
name: step.name,
description: step.description,
hasIssues: step.hasIssues,
tool: step.config?.tool_name,
},
};
localNodes.push(node);
nodes.push(node);
// Connect to previous node
if (previousNodeId) {
const edge = {
id: `${previousNodeId}->${nodeId}`,
source: previousNodeId,
target: nodeId,
type: 'workflow',
};
localEdges.push(edge);
edges.push(edge);
}
// Check if this step has nested condition children
if (step.children && step.children.length > 0) {
const conditionChildren = step.children.filter(child => child.type === 'condition');
const regularChildren = step.children.filter(child => child.type !== 'condition');
if (conditionChildren.length > 0) {
console.log(`Step ${step.name} has ${conditionChildren.length} condition children`);
// Process nested conditions as branches - tighter spacing
const branchResult = processConditionBranches(
conditionChildren,
nodeId,
currentY + 120, // Reduced from 150 to 120 for tighter spacing
xOffset
);
localNodes.push(...branchResult.nodes);
localEdges.push(...branchResult.edges);
nodes.push(...branchResult.nodes);
edges.push(...branchResult.edges);
// Update tracking
currentY = branchResult.maxY;
lastNodes = branchResult.lastNodes;
} else if (regularChildren.length > 0) {
// Process regular children recursively
const childResult = processSteps(
regularChildren,
nodeId,
currentY + 150, // Reduced from 150 to 120 for consistency
xOffset
);
localNodes.push(...childResult.nodes);
localEdges.push(...childResult.edges);
nodes.push(...childResult.nodes);
edges.push(...childResult.edges);
currentY = childResult.maxY;
lastNodes = childResult.lastNodes;
} else {
previousNodeId = nodeId;
lastNodes = [nodeId];
currentY += 120; // Reduced from 150 to 120 for consistency
}
} else {
previousNodeId = nodeId;
lastNodes = [nodeId];
currentY += 120; // Reduced from 150 to 120 for consistency
}
}
}
return { nodes: localNodes, edges: localEdges, lastNodes, maxY: currentY };
};
// Helper function to process condition branches
const processConditionBranches = (
conditions: ConditionalStep[],
parentId: string | null,
yOffset: number,
xOffset: number
): { nodes: Node[]; edges: Edge[]; lastNodes: string[]; maxY: number } => {
const branchNodes: Node[] = [];
const branchEdges: Edge[] = [];
const branchLastNodes: string[] = [];
let maxY = yOffset;
// Calculate x positions for branches - natural spacing
const branchCount = conditions.length;
const xSpacing = 200; // Clean spacing for React Flow
const startX = xOffset - ((branchCount - 1) * xSpacing / 2);
// Process each condition as a branch
conditions.forEach((condition, index) => {
const conditionNodeId = condition.id || uuid();
const branchXPos = startX + index * xSpacing;
// Create condition label node with natural positioning
const conditionNode: Node = {
id: conditionNodeId,
type: 'condition',
position: { x: branchXPos, y: yOffset + 100 },
data: {
conditionType: condition.conditions?.type || 'if',
expression: condition.conditions?.expression || '',
},
};
branchNodes.push(conditionNode);
// Connect parent to condition
if (parentId) {
branchEdges.push({
id: `${parentId}->${conditionNodeId}`,
source: parentId,
target: conditionNodeId,
type: 'workflow',
label: condition.conditions?.type === 'if' ? 'if' :
condition.conditions?.type === 'elseif' ? 'else if' : 'else',
labelStyle: { fill: '#666', fontSize: 12 },
labelBgStyle: { fill: '#fff' },
});
}
// Process children of this condition
if (condition.children && condition.children.length > 0) {
const childResult = processSteps(
condition.children,
conditionNodeId,
yOffset + 180, // Natural spacing for React Flow
branchXPos
);
branchNodes.push(...childResult.nodes);
branchEdges.push(...childResult.edges);
// Track the last nodes in this branch
branchLastNodes.push(...childResult.lastNodes);
maxY = Math.max(maxY, childResult.maxY);
} else {
// Empty branch - condition is the end
branchLastNodes.push(conditionNodeId);
maxY = Math.max(maxY, yOffset + 180);
}
});
return {
nodes: branchNodes,
edges: branchEdges,
lastNodes: branchLastNodes,
maxY,
};
};
// Process all top-level steps
const result = processSteps(steps, null, 0, 0);
console.log('=== Final React Flow result ===');
console.log('Nodes:', nodes.length);
console.log('Edges:', edges.length);
console.log('Node names:', nodes.map(n => `${n.id}:${n.data.name}(${n.type})`));
return { nodes, edges };
}
// Simple, clean conversion approach
export function convertReactFlowToWorkflow(nodes: Node[], edges: Edge[]): ConditionalStep[] {
console.log('=== CLEAN convertReactFlowToWorkflow ===');
console.log('Input nodes:', nodes.map(n => `${n.id}:${n.data?.name || 'unnamed'}(${n.type})`));
console.log('Input edges:', edges.map(e => `${e.source}->${e.target}(${e.label || 'no-label'})`));
if (nodes.length === 0) {
return [];
}
// Build adjacency map
const childrenMap = new Map<string, Node[]>();
const parentMap = new Map<string, string>();
const edgeLabels = new Map<string, string>();
nodes.forEach(node => {
childrenMap.set(node.id, []);
});
edges.forEach(edge => {
const children = childrenMap.get(edge.source) || [];
const targetNode = nodes.find(n => n.id === edge.target);
if (targetNode) {
children.push(targetNode);
parentMap.set(edge.target, edge.source);
if (edge.label) {
edgeLabels.set(edge.target, String(edge.label));
}
}
});
// Find root
const rootNode = nodes.find(node => !parentMap.has(node.id));
if (!rootNode) {
console.log('No root node found');
return [];
}
console.log('Root node:', rootNode.id, rootNode.data?.name);
// Track visited nodes to prevent duplicates
const visited = new Set<string>();
// Convert node to step
const convertNode = (node: Node): ConditionalStep => {
// Mark as visited
visited.add(node.id);
const nodeData = node.data as any;
const children = childrenMap.get(node.id) || [];
if (node.type === 'step') {
const step: ConditionalStep = {
id: node.id,
name: nodeData.name || 'Unnamed Step',
description: nodeData.description || '',
type: 'instruction',
config: nodeData.tool ? { tool_name: nodeData.tool } : {},
order: 0,
enabled: true,
hasIssues: nodeData.hasIssues || false,
children: []
};
// Separate condition children from regular children
const conditionChildren = children.filter(child => child.type === 'condition');
const regularChildren = children.filter(child => child.type !== 'condition');
console.log(`Step ${step.name}: ${conditionChildren.length} conditions, ${regularChildren.length} regular children`);
// Process condition children as nested conditions
conditionChildren.forEach(conditionChild => {
if (!visited.has(conditionChild.id)) {
const conditionStep = convertNode(conditionChild);
step.children!.push(conditionStep);
}
});
// Return step with regular children to be processed as siblings
return step;
} else if (node.type === 'condition') {
const edgeLabel = edgeLabels.get(node.id) || 'Condition';
const step: ConditionalStep = {
id: node.id,
name: edgeLabel,
description: '',
type: 'condition',
config: {},
conditions: {
type: nodeData.conditionType || 'if',
expression: nodeData.expression || '',
},
order: 0,
enabled: true,
hasIssues: false,
children: []
};
// Process all children of this condition
children.forEach(child => {
if (!visited.has(child.id)) {
const childStep = convertNode(child);
step.children!.push(childStep);
// Process any regular children as siblings
const childRegularChildren = (childrenMap.get(child.id) || []).filter(c => c.type !== 'condition');
childRegularChildren.forEach(grandchild => {
if (!visited.has(grandchild.id)) {
const grandchildStep = convertNode(grandchild);
step.children!.push(grandchildStep);
}
});
}
});
return step;
}
// Fallback
return {
id: node.id,
name: 'Unknown',
description: '',
type: 'instruction',
config: {},
order: 0,
enabled: true,
hasIssues: true,
children: []
};
};
// Process from root
const result: ConditionalStep[] = [];
const rootStep = convertNode(rootNode);
result.push(rootStep);
// Process any unvisited regular children of root as siblings
const rootRegularChildren = (childrenMap.get(rootNode.id) || []).filter(child =>
child.type !== 'condition' && !visited.has(child.id)
);
rootRegularChildren.forEach(child => {
const childStep = convertNode(child);
result.push(childStep);
});
console.log('=== Final clean workflow result ===');
console.log('Steps:', result.length);
console.log('Visited nodes:', visited.size, 'of', nodes.length);
console.log('Step details:', result.map(s => ({
name: s.name,
type: s.type,
childrenCount: s.children?.length || 0,
conditions: s.conditions?.type || 'none'
})));
return result;
}