diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000..a2140a12 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": "npx ultracite fix" + } + ] + } +} diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc new file mode 100644 index 00000000..07d76ffe --- /dev/null +++ b/.cursor/rules/ultracite.mdc @@ -0,0 +1,129 @@ +--- +description: Ultracite Rules - AI-Ready Formatter and Linter +globs: "**/*.{ts,tsx,js,jsx,json,jsonc,html,vue,svelte,astro,css,yaml,yml,graphql,gql,md,mdx,grit}" +alwaysApply: false +--- + +# Ultracite Code Standards + +This project uses **Ultracite**, a zero-config Biome preset that enforces strict code quality standards through automated formatting and linting. + +## Quick Reference + +- **Format code**: `npx ultracite fix` +- **Check for issues**: `npx ultracite check` +- **Diagnose setup**: `npx ultracite doctor` + +Biome (the underlying engine) provides extremely fast Rust-based linting and formatting. Most issues are automatically fixable. + +--- + +## Core Principles + +Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. + +### Type Safety & Explicitness + +- Use explicit types for function parameters and return values when they enhance clarity +- Prefer `unknown` over `any` when the type is genuinely unknown +- Use const assertions (`as const`) for immutable values and literal types +- Leverage TypeScript's type narrowing instead of type assertions +- Use meaningful variable names instead of magic numbers - extract constants with descriptive names + +### Modern JavaScript/TypeScript + +- Use arrow functions for callbacks and short functions +- Prefer `for...of` loops over `.forEach()` and indexed `for` loops +- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access +- Prefer template literals over string concatenation +- Use destructuring for object and array assignments +- Use `const` by default, `let` only when reassignment is needed, never `var` + +### Async & Promises + +- Always `await` promises in async functions - don't forget to use the return value +- Use `async/await` syntax instead of promise chains for better readability +- Handle errors appropriately in async code with try-catch blocks +- Don't use async functions as Promise executors + +### React & JSX + +- Use function components over class components +- Call hooks at the top level only, never conditionally +- Specify all dependencies in hook dependency arrays correctly +- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) +- Nest children between opening and closing tags instead of passing as props +- Don't define components inside other components +- Use semantic HTML and ARIA attributes for accessibility: + - Provide meaningful alt text for images + - Use proper heading hierarchy + - Add labels for form inputs + - Include keyboard event handlers alongside mouse events + - Use semantic elements (` - - - - - - - - ); -} diff --git a/app/page.tsx b/app/page.tsx index 153535c3..228349a0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,15 +1,138 @@ -'use client'; - -import { Provider } from 'jotai'; -import { AuthProvider } from '@/components/auth/auth-provider'; -import { WorkflowsList } from '@/components/workflows/workflows-list'; - -export default function Home() { - return ( - - - - - - ); +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { nanoid } from "nanoid"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { api } from "@/lib/api-client"; +import { authClient, useSession } from "@/lib/auth-client"; +import { + currentWorkflowNameAtom, + edgesAtom, + hasSidebarBeenShownAtom, + isTransitioningFromHomepageAtom, + nodesAtom, + type WorkflowNode, +} from "@/lib/workflow-store"; + +// Helper function to create a default trigger node +function createDefaultTriggerNode() { + return { + id: nanoid(), + type: "trigger" as const, + position: { x: 0, y: 0 }, + data: { + label: "", + description: "", + type: "trigger" as const, + config: { triggerType: "Manual" }, + status: "idle" as const, + }, + }; } + +const Home = () => { + const router = useRouter(); + const { data: session } = useSession(); + const nodes = useAtomValue(nodesAtom); + const edges = useAtomValue(edgesAtom); + const setNodes = useSetAtom(nodesAtom); + const setEdges = useSetAtom(edgesAtom); + const setCurrentWorkflowName = useSetAtom(currentWorkflowNameAtom); + const setHasSidebarBeenShown = useSetAtom(hasSidebarBeenShownAtom); + const setIsTransitioningFromHomepage = useSetAtom( + isTransitioningFromHomepageAtom + ); + const hasCreatedWorkflowRef = useRef(false); + const currentWorkflowName = useAtomValue(currentWorkflowNameAtom); + + // Reset sidebar animation state when on homepage + useEffect(() => { + setHasSidebarBeenShown(false); + }, [setHasSidebarBeenShown]); + + // Update page title when workflow name changes + useEffect(() => { + document.title = `${currentWorkflowName} - AI Workflow Builder`; + }, [currentWorkflowName]); + + // Helper to create anonymous session if needed + const ensureSession = useCallback(async () => { + if (!session) { + await authClient.signIn.anonymous(); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + }, [session]); + + // Handler to add the first node (replaces the "add" node) + const handleAddNode = useCallback(() => { + const newNode: WorkflowNode = createDefaultTriggerNode(); + // Replace all nodes (removes the "add" node) + setNodes([newNode]); + }, [setNodes]); + + // Initialize with a temporary "add" node on mount + useEffect(() => { + const addNodePlaceholder: WorkflowNode = { + id: "add-node-placeholder", + type: "add", + position: { x: 0, y: 0 }, + data: { + label: "", + type: "add", + onClick: handleAddNode, + }, + draggable: false, + selectable: false, + }; + setNodes([addNodePlaceholder]); + setEdges([]); + setCurrentWorkflowName("New Workflow"); + hasCreatedWorkflowRef.current = false; + }, [setNodes, setEdges, setCurrentWorkflowName, handleAddNode]); + + // Create workflow when first real node is added + useEffect(() => { + const createWorkflowAndRedirect = async () => { + // Filter out the placeholder "add" node + const realNodes = nodes.filter((node) => node.type !== "add"); + + // Only create when we have at least one real node and haven't created a workflow yet + if (realNodes.length === 0 || hasCreatedWorkflowRef.current) { + return; + } + hasCreatedWorkflowRef.current = true; + + try { + await ensureSession(); + + // Create workflow with all real nodes + const newWorkflow = await api.workflow.create({ + name: "Untitled Workflow", + description: "", + nodes: realNodes, + edges, + }); + + // Set flags to indicate we're coming from homepage (for sidebar animation) + sessionStorage.setItem("animate-sidebar", "true"); + setIsTransitioningFromHomepage(true); + + // Redirect to the workflow page + console.log("[Homepage] Navigating to workflow page"); + router.replace(`/workflows/${newWorkflow.id}`); + } catch (error) { + console.error("Failed to create workflow:", error); + toast.error("Failed to create workflow"); + } + }; + + createWorkflowAndRedirect(); + }, [nodes, edges, router, ensureSession, setIsTransitioningFromHomepage]); + + // Canvas and toolbar are rendered by PersistentCanvas in the layout + return null; +}; + +export default Home; diff --git a/app/settings/page.tsx b/app/settings/page.tsx deleted file mode 100644 index e9f83cde..00000000 --- a/app/settings/page.tsx +++ /dev/null @@ -1,561 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { AppHeader } from '@/components/app-header'; -import { Plus, Trash2 } from 'lucide-react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; - -interface Integrations { - resendApiKey: string | null; - resendFromEmail: string | null; - linearApiKey: string | null; - slackApiKey: string | null; - hasResendKey: boolean; - hasLinearKey: boolean; - hasSlackKey: boolean; -} - -interface DataSource { - id: string; - name: string; - type: 'postgresql' | 'mysql' | 'mongodb'; - connectionString: string; - isDefault: boolean; - createdAt: string; - updatedAt: string; -} - -export default function SettingsPage() { - const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState('account'); - - // Account state - const [accountName, setAccountName] = useState(''); - const [accountEmail, setAccountEmail] = useState(''); - const [savingAccount, setSavingAccount] = useState(false); - - // Integrations state - const [integrations, setIntegrations] = useState(null); - const [resendApiKey, setResendApiKey] = useState(''); - const [resendFromEmail, setResendFromEmail] = useState(''); - const [linearApiKey, setLinearApiKey] = useState(''); - const [slackApiKey, setSlackApiKey] = useState(''); - const [savingIntegrations, setSavingIntegrations] = useState(false); - - // Data sources state - const [dataSources, setDataSources] = useState([]); - const [showAddSource, setShowAddSource] = useState(false); - const [newSourceName, setNewSourceName] = useState(''); - const [newSourceConnectionString, setNewSourceConnectionString] = useState(''); - const [newSourceIsDefault, setNewSourceIsDefault] = useState(false); - const [savingSource, setSavingSource] = useState(false); - const [deleteSourceId, setDeleteSourceId] = useState(null); - - const loadAll = async () => { - setLoading(true); - try { - await Promise.all([loadAccount(), loadIntegrations(), loadDataSources()]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadAll(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const loadAccount = async () => { - try { - const response = await fetch('/api/user/account'); - if (response.ok) { - const data = await response.json(); - setAccountName(data.name || ''); - setAccountEmail(data.email || ''); - } - } catch (error) { - console.error('Failed to load account:', error); - } - }; - - const loadIntegrations = async () => { - try { - const response = await fetch('/api/user/integrations'); - if (response.ok) { - const data = await response.json(); - setIntegrations(data); - setResendFromEmail(data.resendFromEmail || ''); - } - } catch (error) { - console.error('Failed to load integrations:', error); - } - }; - - const loadDataSources = async () => { - try { - const response = await fetch('/api/user/data-sources'); - if (response.ok) { - const data = await response.json(); - setDataSources(data.dataSources || []); - } - } catch (error) { - console.error('Failed to load data sources:', error); - } - }; - - const saveAccount = async () => { - setSavingAccount(true); - try { - const response = await fetch('/api/user/account', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: accountName, email: accountEmail }), - }); - - if (response.ok) { - await loadAccount(); - } - } catch (error) { - console.error('Failed to save account:', error); - } finally { - setSavingAccount(false); - } - }; - - const saveIntegrations = async () => { - setSavingIntegrations(true); - try { - const updates: { - resendApiKey?: string; - resendFromEmail?: string; - linearApiKey?: string; - slackApiKey?: string; - } = {}; - - if (resendApiKey) updates.resendApiKey = resendApiKey; - if (resendFromEmail) updates.resendFromEmail = resendFromEmail; - if (linearApiKey) updates.linearApiKey = linearApiKey; - if (slackApiKey) updates.slackApiKey = slackApiKey; - - const response = await fetch('/api/user/integrations', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); - - if (response.ok) { - await loadIntegrations(); - setResendApiKey(''); - setLinearApiKey(''); - setSlackApiKey(''); - } - } catch (error) { - console.error('Failed to save integrations:', error); - } finally { - setSavingIntegrations(false); - } - }; - - const addDataSource = async () => { - if (!newSourceName || !newSourceConnectionString) return; - - setSavingSource(true); - try { - const response = await fetch('/api/user/data-sources', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: newSourceName, - type: 'postgresql', - connectionString: newSourceConnectionString, - isDefault: newSourceIsDefault, - }), - }); - - if (response.ok) { - await loadDataSources(); - setNewSourceName(''); - setNewSourceConnectionString(''); - setNewSourceIsDefault(false); - setShowAddSource(false); - } - } catch (error) { - console.error('Failed to add data source:', error); - } finally { - setSavingSource(false); - } - }; - - const deleteDataSource = async (id: string) => { - try { - const response = await fetch(`/api/user/data-sources/${id}`, { - method: 'DELETE', - }); - - if (response.ok) { - await loadDataSources(); - setDeleteSourceId(null); - } - } catch (error) { - console.error('Failed to delete data source:', error); - } - }; - - if (loading) { - return ( -
- -
-
Loading...
-
-
- ); - } - - return ( -
- -
-
-

Settings

-

- Manage your account, integrations, and data sources -

-
- - - - Account - Integrations - Sources - - - - - - Account Information - Update your personal information - - -
- - setAccountName(e.target.value)} - placeholder="Your name" - /> -
- -
- - setAccountEmail(e.target.value)} - placeholder="your.email@example.com" - /> -
- -
- -
-
-
-
- - - - - Resend (Email) - - Configure your Resend API key to send emails from workflows - - - -
- - setResendApiKey(e.target.value)} - placeholder={ - integrations?.hasResendKey - ? 'API key is configured' - : 'Enter your Resend API key' - } - /> -

- Get your API key from{' '} - - resend.com/api-keys - -

-
- -
- - setResendFromEmail(e.target.value)} - placeholder="noreply@yourdomain.com" - /> -

- The email address that will appear as the sender -

-
-
-
- - - - Linear - - Configure your Linear API key to create and manage tickets from workflows - - - -
- - setLinearApiKey(e.target.value)} - placeholder={ - integrations?.hasLinearKey - ? 'API key is configured' - : 'Enter your Linear API key' - } - /> -

- Get your API key from{' '} - - Linear API Settings - -

-
-
-
- - - - Slack - - Configure your Slack Bot Token to send messages from workflows - - - -
- - setSlackApiKey(e.target.value)} - placeholder={ - integrations?.hasSlackKey - ? 'Bot token is configured' - : 'Enter your Slack Bot Token' - } - /> -

- Create a Slack app and get your Bot Token from{' '} - - api.slack.com/apps - -

-
- -
- -
-
-
-
- - - - -
-
- Data Sources - Manage your database connections -
- -
-
- - {dataSources.length === 0 ? ( -
- No data sources configured. Add one to get started. -
- ) : ( -
- {dataSources.map((source) => ( -
-
-
-

{source.name}

- {source.isDefault && ( - - Default - - )} -
-

- Type: {source.type.toUpperCase()} -

-

- {source.connectionString} -

-
- -
- ))} -
- )} -
-
- - {showAddSource && ( - - - Add Data Source - Configure a new database connection - - -
- - setNewSourceName(e.target.value)} - placeholder="Production Database" - /> -
- -
- - -

- Currently only PostgreSQL is supported -

-
- -
- - setNewSourceConnectionString(e.target.value)} - placeholder="postgresql://user:password@host:port/database" - /> -

- Format: postgresql://username:password@host:port/database -

-
- -
- setNewSourceIsDefault(e.target.checked)} - className="h-4 w-4 cursor-pointer rounded border-gray-300" - /> - -
- -
- - -
-
-
- )} -
-
-
- - !open && setDeleteSourceId(null)} - > - - - Delete Data Source - - Are you sure you want to delete this data source? This action cannot be undone. - - - - Cancel - deleteSourceId && deleteDataSource(deleteSourceId)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Delete - - - - -
- ); -} diff --git a/app/workflows/[workflowId]/layout.tsx b/app/workflows/[workflowId]/layout.tsx new file mode 100644 index 00000000..4abcfbaf --- /dev/null +++ b/app/workflows/[workflowId]/layout.tsx @@ -0,0 +1,78 @@ +import { eq } from "drizzle-orm"; +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { db } from "@/lib/db"; +import { workflows } from "@/lib/db/schema"; + +type WorkflowLayoutProps = { + children: ReactNode; + params: Promise<{ workflowId: string }>; +}; + +export async function generateMetadata({ + params, +}: WorkflowLayoutProps): Promise { + const { workflowId } = await params; + + // Try to fetch the workflow to get its name + let title = "Workflow"; + let isPublic = false; + + try { + const workflow = await db.query.workflows.findFirst({ + where: eq(workflows.id, workflowId), + columns: { + name: true, + visibility: true, + }, + }); + + if (workflow) { + isPublic = workflow.visibility === "public"; + // Only expose workflow name in metadata if it's public + // This prevents private workflow name enumeration + if (isPublic) { + title = workflow.name; + } + } + } catch { + // Ignore errors, use defaults + } + + const baseUrl = + process.env.NEXT_PUBLIC_APP_URL || "https://workflow-builder.dev"; + const workflowUrl = `${baseUrl}/workflows/${workflowId}`; + const ogImageUrl = isPublic + ? `${baseUrl}/api/og/workflow/${workflowId}` + : `${baseUrl}/og-default.png`; + + return { + title: `${title} | AI Workflow Builder`, + description: `View and explore the "${title}" workflow built with AI Workflow Builder.`, + openGraph: { + title: `${title} | AI Workflow Builder`, + description: `View and explore the "${title}" workflow built with AI Workflow Builder.`, + type: "website", + url: workflowUrl, + siteName: "AI Workflow Builder", + images: [ + { + url: ogImageUrl, + width: 1200, + height: 630, + alt: `${title} workflow visualization`, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: `${title} | AI Workflow Builder`, + description: `View and explore the "${title}" workflow built with AI Workflow Builder.`, + images: [ogImageUrl], + }, + }; +} + +export default function WorkflowLayout({ children }: WorkflowLayoutProps) { + return children; +} diff --git a/app/workflows/[workflowId]/page.tsx b/app/workflows/[workflowId]/page.tsx index 07ff0c65..c760640d 100644 --- a/app/workflows/[workflowId]/page.tsx +++ b/app/workflows/[workflowId]/page.tsx @@ -1,104 +1,412 @@ -'use client'; - -import { use, useEffect, useCallback } from 'react'; -import { Provider, useSetAtom, useAtom } from 'jotai'; -import { useSearchParams } from 'next/navigation'; -import { ReactFlowProvider } from '@xyflow/react'; -import { WorkflowCanvas } from '@/components/workflow/workflow-canvas'; -import { NodeToolbar } from '@/components/workflow/node-toolbar'; -import { NodeConfigPanel } from '@/components/workflow/node-config-panel'; -import { WorkflowToolbar } from '@/components/workflow/workflow-toolbar'; +"use client"; + +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { use, useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { NodeConfigPanel } from "@/components/workflow/node-config-panel"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { api } from "@/lib/api-client"; +import { + integrationsAtom, + integrationsLoadedAtom, + integrationsVersionAtom, +} from "@/lib/integrations-store"; +import type { IntegrationType } from "@/lib/types/integration"; import { - nodesAtom, - edgesAtom, currentWorkflowIdAtom, currentWorkflowNameAtom, - isLoadingAtom, + currentWorkflowVisibilityAtom, + edgesAtom, + hasSidebarBeenShownAtom, + hasUnsavedChangesAtom, isGeneratingAtom, - isExecutingAtom, + isPanelAnimatingAtom, + isSavingAtom, + isSidebarCollapsedAtom, + isWorkflowOwnerAtom, + nodesAtom, + rightPanelWidthAtom, + selectedExecutionIdAtom, + triggerExecuteAtom, updateNodeDataAtom, -} from '@/lib/workflow-store'; -import { AuthProvider } from '@/components/auth/auth-provider'; -import { workflowApi } from '@/lib/workflow-api'; + type WorkflowNode, + type WorkflowVisibility, + workflowNotFoundAtom, +} from "@/lib/workflow-store"; +import { findActionById } from "@/plugins"; + +type WorkflowPageProps = { + params: Promise<{ workflowId: string }>; +}; + +// System actions that need integrations (not in plugin registry) +const SYSTEM_ACTION_INTEGRATIONS: Record = { + "Database Query": "database", +}; + +// Helper to get required integration type for an action +function getRequiredIntegrationType( + actionType: string +): IntegrationType | undefined { + const action = findActionById(actionType); + return ( + (action?.integration as IntegrationType | undefined) || + SYSTEM_ACTION_INTEGRATIONS[actionType] + ); +} + +// Helper to check and fix a single node's integration +type IntegrationFixResult = { + nodeId: string; + newIntegrationId: string | undefined; +}; + +function checkNodeIntegration( + node: WorkflowNode, + allIntegrations: { id: string; type: string }[], + validIntegrationIds: Set +): IntegrationFixResult | null { + const actionType = node.data.config?.actionType as string | undefined; + if (!actionType) { + return null; + } + + const integrationType = getRequiredIntegrationType(actionType); + if (!integrationType) { + return null; + } + + const currentIntegrationId = node.data.config?.integrationId as + | string + | undefined; + const hasValidIntegration = + currentIntegrationId && validIntegrationIds.has(currentIntegrationId); + + if (hasValidIntegration) { + return null; + } + + // Find available integrations of this type + const available = allIntegrations.filter((i) => i.type === integrationType); + + if (available.length === 1) { + return { nodeId: node.id, newIntegrationId: available[0].id }; + } + if (available.length === 0 && currentIntegrationId) { + return { nodeId: node.id, newIntegrationId: undefined }; + } + return null; +} -function WorkflowEditor({ params }: { params: Promise<{ workflowId: string }> }) { +const WorkflowEditor = ({ params }: WorkflowPageProps) => { const { workflowId } = use(params); const searchParams = useSearchParams(); - const [isLoading, setIsLoading] = useAtom(isLoadingAtom); + const isMobile = useIsMobile(); const [isGenerating, setIsGenerating] = useAtom(isGeneratingAtom); - const [isExecuting, setIsExecuting] = useAtom(isExecutingAtom); + const [_isSaving, setIsSaving] = useAtom(isSavingAtom); const [nodes] = useAtom(nodesAtom); const [edges] = useAtom(edgesAtom); const [currentWorkflowId] = useAtom(currentWorkflowIdAtom); + const [selectedExecutionId] = useAtom(selectedExecutionIdAtom); const setNodes = useSetAtom(nodesAtom); const setEdges = useSetAtom(edgesAtom); const setCurrentWorkflowId = useSetAtom(currentWorkflowIdAtom); const setCurrentWorkflowName = useSetAtom(currentWorkflowNameAtom); const updateNodeData = useSetAtom(updateNodeDataAtom); + const setHasUnsavedChanges = useSetAtom(hasUnsavedChangesAtom); + const [workflowNotFound, setWorkflowNotFound] = useAtom(workflowNotFoundAtom); + const setTriggerExecute = useSetAtom(triggerExecuteAtom); + const setRightPanelWidth = useSetAtom(rightPanelWidthAtom); + const setIsPanelAnimating = useSetAtom(isPanelAnimatingAtom); + const [hasSidebarBeenShown, setHasSidebarBeenShown] = useAtom( + hasSidebarBeenShownAtom + ); + const [panelCollapsed, setPanelCollapsed] = useAtom(isSidebarCollapsedAtom); + const setCurrentWorkflowVisibility = useSetAtom( + currentWorkflowVisibilityAtom + ); + const setIsWorkflowOwner = useSetAtom(isWorkflowOwnerAtom); + const setGlobalIntegrations = useSetAtom(integrationsAtom); + const setIntegrationsLoaded = useSetAtom(integrationsLoadedAtom); + const integrationsVersion = useAtomValue(integrationsVersionAtom); + + // Panel width state for resizing + const [panelWidth, setPanelWidth] = useState(30); // default percentage + // Start visible if sidebar has already been shown (switching between workflows) + const [panelVisible, setPanelVisible] = useState(hasSidebarBeenShown); + const [isDraggingResize, setIsDraggingResize] = useState(false); + const isResizing = useRef(false); + const hasReadCookies = useRef(false); + // Read sidebar preferences from cookies on mount (after hydration) useEffect(() => { - const loadWorkflowData = async () => { - const isGeneratingParam = searchParams?.get('generating') === 'true'; - const storedPrompt = sessionStorage.getItem('ai-prompt'); - const storedWorkflowId = sessionStorage.getItem('generating-workflow-id'); - - // Check if we should generate - if (isGeneratingParam && storedPrompt && storedWorkflowId === workflowId) { - // Clear session storage - sessionStorage.removeItem('ai-prompt'); - sessionStorage.removeItem('generating-workflow-id'); - - // Set generating state - setIsGenerating(true); - setCurrentWorkflowId(workflowId); - setCurrentWorkflowName('AI Generated Workflow'); - - try { - // Stream the AI response - const response = await fetch('/api/ai/generate-workflow', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: storedPrompt }), - }); + if (hasReadCookies.current) { + return; + } + hasReadCookies.current = true; - if (!response.ok) { - throw new Error('Failed to generate workflow'); - } + // Read width + const widthCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("sidebar-width=")); + if (widthCookie) { + const value = Number.parseFloat(widthCookie.split("=")[1]); + if (!Number.isNaN(value) && value >= 20 && value <= 50) { + setPanelWidth(value); + } + } - const workflowData = await response.json(); + // Read collapsed state + const collapsedCookie = document.cookie + .split("; ") + .find((row) => row.startsWith("sidebar-collapsed=")); + if (collapsedCookie) { + setPanelCollapsed(collapsedCookie.split("=")[1] === "true"); + } + }, [setPanelCollapsed]); - // Update nodes and edges as they come in - setNodes(workflowData.nodes || []); - setEdges(workflowData.edges || []); - setCurrentWorkflowName(workflowData.name || 'AI Generated Workflow'); + // Save sidebar width to cookie when it changes (skip initial render) + const hasInitialized = useRef(false); + useEffect(() => { + if (!hasInitialized.current) { + hasInitialized.current = true; + return; + } + // biome-ignore lint/suspicious/noDocumentCookie: simple cookie storage for sidebar width + document.cookie = `sidebar-width=${panelWidth}; path=/; max-age=31536000`; // 1 year + }, [panelWidth]); - // Save to database - await workflowApi.update(workflowId, { - name: workflowData.name, - description: workflowData.description, - nodes: workflowData.nodes, - edges: workflowData.edges, - }); - } catch (error) { - console.error('Failed to generate workflow:', error); - alert('Failed to generate workflow'); - } finally { - setIsGenerating(false); - } + // Save collapsed state to cookie when it changes + useEffect(() => { + if (!hasReadCookies.current) { + return; + } + // biome-ignore lint/suspicious/noDocumentCookie: simple cookie storage for sidebar state + document.cookie = `sidebar-collapsed=${panelCollapsed}; path=/; max-age=31536000`; // 1 year + }, [panelCollapsed]); + + // Trigger slide-in animation on mount (only for homepage -> workflow transition) + useEffect(() => { + // Check if we came from homepage + const shouldAnimate = sessionStorage.getItem("animate-sidebar") === "true"; + sessionStorage.removeItem("animate-sidebar"); + + // Skip animation if sidebar has already been shown (switching between workflows) + // or if we didn't come from homepage (direct load, refresh) + if (hasSidebarBeenShown || !shouldAnimate) { + setPanelVisible(true); + setHasSidebarBeenShown(true); + return; + } + + // Set animating state before starting + setIsPanelAnimating(true); + // Delay to ensure the canvas is visible at full width first + const timer = setTimeout(() => { + setPanelVisible(true); + setHasSidebarBeenShown(true); + }, 100); + // Clear animating state after animation completes (300ms + buffer) + const animationTimer = setTimeout(() => setIsPanelAnimating(false), 400); + return () => { + clearTimeout(timer); + clearTimeout(animationTimer); + setIsPanelAnimating(false); + }; + }, [hasSidebarBeenShown, setHasSidebarBeenShown, setIsPanelAnimating]); + + // Keyboard shortcut Cmd/Ctrl+B to toggle sidebar + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "b") { + e.preventDefault(); + setIsPanelAnimating(true); + setPanelCollapsed((prev) => !prev); + setTimeout(() => setIsPanelAnimating(false), 350); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [setIsPanelAnimating, setPanelCollapsed]); + + // Set right panel width for AI prompt positioning + // Only set it after the panel is visible (animated in) to coordinate the animation + useEffect(() => { + if (!isMobile && panelVisible && !panelCollapsed) { + setRightPanelWidth(`${panelWidth}%`); + } else { + // During initial render or when collapsed, set to null so prompt is centered + setRightPanelWidth(null); + } + return () => { + setRightPanelWidth(null); + }; + }, [isMobile, setRightPanelWidth, panelWidth, panelVisible, panelCollapsed]); + + // Handle panel resize + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + setIsDraggingResize(true); + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!isResizing.current) { + return; + } + const newWidth = + ((window.innerWidth - moveEvent.clientX) / window.innerWidth) * 100; + // Clamp between 20% and 50% + setPanelWidth(Math.min(50, Math.max(20, newWidth))); + }; + + const handleMouseUp = () => { + isResizing.current = false; + setIsDraggingResize(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, []); + + // Ref to track polling interval + const executionPollingIntervalRef = useRef(null); + // Ref to track polling interval for selected execution + const selectedExecutionPollingIntervalRef = useRef( + null + ); + // Ref to access current nodes without triggering effect re-runs + const nodesRef = useRef(nodes); + + // Keep nodes ref in sync + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + // Helper function to generate workflow from AI + const generateWorkflowFromAI = useCallback( + async (prompt: string) => { + setIsGenerating(true); + setCurrentWorkflowId(workflowId); + setCurrentWorkflowName("AI Generated Workflow"); + + try { + const workflowData = await api.ai.generate(prompt); + + // Clear selection on all nodes + const nodesWithoutSelection = (workflowData.nodes || []).map( + (node: WorkflowNode) => ({ ...node, selected: false }) + ); + setNodes(nodesWithoutSelection); + setEdges(workflowData.edges || []); + setCurrentWorkflowName(workflowData.name || "AI Generated Workflow"); + + await api.workflow.update(workflowId, { + name: workflowData.name, + description: workflowData.description, + nodes: workflowData.nodes, + edges: workflowData.edges, + }); + } catch (error) { + console.error("Failed to generate workflow:", error); + toast.error("Failed to generate workflow"); + } finally { + setIsGenerating(false); + } + }, + [ + workflowId, + setIsGenerating, + setCurrentWorkflowId, + setCurrentWorkflowName, + setNodes, + setEdges, + ] + ); + + // Helper function to load existing workflow + const loadExistingWorkflow = useCallback(async () => { + try { + const workflow = await api.workflow.getById(workflowId); + + if (!workflow) { + setWorkflowNotFound(true); + return; + } + + // Reset node statuses to idle and clear selection when loading from database + const nodesWithIdleStatus = workflow.nodes.map((node: WorkflowNode) => ({ + ...node, + selected: false, + data: { + ...node.data, + status: "idle" as const, + }, + })); + + setNodes(nodesWithIdleStatus); + setEdges(workflow.edges); + setCurrentWorkflowId(workflow.id); + setCurrentWorkflowName(workflow.name); + setCurrentWorkflowVisibility( + (workflow.visibility as WorkflowVisibility) ?? "private" + ); + setIsWorkflowOwner(workflow.isOwner !== false); // Default to true if not set + setHasUnsavedChanges(false); + setWorkflowNotFound(false); + } catch (error) { + console.error("Failed to load workflow:", error); + toast.error("Failed to load workflow"); + } + }, [ + workflowId, + setNodes, + setEdges, + setCurrentWorkflowId, + setCurrentWorkflowName, + setCurrentWorkflowVisibility, + setIsWorkflowOwner, + setHasUnsavedChanges, + setWorkflowNotFound, + ]); + + // Track if we've already auto-fixed integrations for this workflow+version + const lastAutoFixRef = useRef<{ workflowId: string; version: number } | null>( + null + ); + + useEffect(() => { + const loadWorkflowData = async () => { + const isGeneratingParam = searchParams?.get("generating") === "true"; + const storedPrompt = sessionStorage.getItem("ai-prompt"); + const storedWorkflowId = sessionStorage.getItem("generating-workflow-id"); + + // Check if state is already loaded for this workflow + if (currentWorkflowId === workflowId && nodes.length > 0) { + return; + } + + // Check if we should generate from AI + if ( + isGeneratingParam && + storedPrompt && + storedWorkflowId === workflowId + ) { + sessionStorage.removeItem("ai-prompt"); + sessionStorage.removeItem("generating-workflow-id"); + await generateWorkflowFromAI(storedPrompt); } else { - // Normal workflow loading - try { - setIsLoading(true); - const workflow = await workflowApi.getById(workflowId); - setNodes(workflow.nodes); - setEdges(workflow.edges); - setCurrentWorkflowId(workflow.id); - setCurrentWorkflowName(workflow.name); - } catch (error) { - console.error('Failed to load workflow:', error); - } finally { - setIsLoading(false); - } + await loadExistingWorkflow(); } }; @@ -106,119 +414,331 @@ function WorkflowEditor({ params }: { params: Promise<{ workflowId: string }> }) }, [ workflowId, searchParams, - setCurrentWorkflowId, - setCurrentWorkflowName, - setNodes, - setEdges, - setIsLoading, - setIsGenerating, + currentWorkflowId, + nodes.length, + generateWorkflowFromAI, + loadExistingWorkflow, + ]); + + // Auto-fix invalid/missing integrations on workflow load or when integrations change + useEffect(() => { + // Skip if no nodes or no workflow + if (nodes.length === 0 || !currentWorkflowId) { + return; + } + + // Skip if already checked for this workflow+version combination + const lastFix = lastAutoFixRef.current; + if ( + lastFix && + lastFix.workflowId === currentWorkflowId && + lastFix.version === integrationsVersion + ) { + return; + } + + const autoFixIntegrations = async () => { + try { + const allIntegrations = await api.integration.getAll(); + setGlobalIntegrations(allIntegrations); + setIntegrationsLoaded(true); + + const validIds = new Set(allIntegrations.map((i) => i.id)); + const fixes = nodes + .map((node) => checkNodeIntegration(node, allIntegrations, validIds)) + .filter((fix): fix is IntegrationFixResult => fix !== null); + + for (const fix of fixes) { + const node = nodes.find((n) => n.id === fix.nodeId); + if (node) { + updateNodeData({ + id: fix.nodeId, + data: { + config: { + ...node.data.config, + integrationId: fix.newIntegrationId, + }, + }, + }); + } + } + + lastAutoFixRef.current = { + workflowId: currentWorkflowId, + version: integrationsVersion, + }; + if (fixes.length > 0) { + setHasUnsavedChanges(true); + } + } catch (error) { + console.error("Failed to auto-fix integrations:", error); + } + }; + + autoFixIntegrations(); + }, [ + nodes, + currentWorkflowId, + integrationsVersion, + updateNodeData, + setGlobalIntegrations, + setIntegrationsLoaded, + setHasUnsavedChanges, ]); // Keyboard shortcuts const handleSave = useCallback(async () => { - if (!currentWorkflowId || isGenerating) return; + if (!currentWorkflowId || isGenerating) { + return; + } + setIsSaving(true); try { - await workflowApi.update(currentWorkflowId, { nodes, edges }); + await api.workflow.update(currentWorkflowId, { nodes, edges }); + setHasUnsavedChanges(false); } catch (error) { - console.error('Failed to save workflow:', error); + console.error("Failed to save workflow:", error); + toast.error("Failed to save workflow"); + } finally { + setIsSaving(false); } - }, [currentWorkflowId, nodes, edges, isGenerating]); + }, [ + currentWorkflowId, + nodes, + edges, + isGenerating, + setIsSaving, + setHasUnsavedChanges, + ]); - const handleRun = useCallback(async () => { - if (isExecuting || nodes.length === 0 || isGenerating || !currentWorkflowId) return; + // Helper to check if target is an input element + const isInputElement = useCallback( + (target: HTMLElement) => + target.tagName === "INPUT" || target.tagName === "TEXTAREA", + [] + ); - setIsExecuting(true); + // Helper to check if we're in Monaco editor + const isInMonacoEditor = useCallback( + (target: HTMLElement) => target.closest(".monaco-editor") !== null, + [] + ); - // Set all nodes to idle first - nodes.forEach((node) => { - updateNodeData({ id: node.id, data: { status: 'idle' } }); - }); + // Helper to handle save shortcut + const handleSaveShortcut = useCallback( + (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + e.stopPropagation(); + handleSave(); + return true; + } + return false; + }, + [handleSave] + ); - try { - // Call the server API to execute the workflow - const response = await fetch(`/api/workflows/${currentWorkflowId}/execute`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: {} }), - }); + // Helper to handle run shortcut + // Uses triggerExecuteAtom to share the same execute flow as the Run button + // This ensures keyboard shortcut goes through the same checks (e.g., missing integrations) + const handleRunShortcut = useCallback( + (e: KeyboardEvent, target: HTMLElement) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + if (!(isInputElement(target) || isInMonacoEditor(target))) { + e.preventDefault(); + e.stopPropagation(); + // Trigger execute via atom - the toolbar will handle it + setTriggerExecute(true); + } + return true; + } + return false; + }, + [setTriggerExecute, isInputElement, isInMonacoEditor] + ); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to execute workflow'); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + + // Handle save shortcut + if (handleSaveShortcut(e)) { + return; } - const result = await response.json(); + // Handle run shortcut + if (handleRunShortcut(e, target)) { + return; + } + }; - // Update all nodes based on result - nodes.forEach((node) => { - updateNodeData({ - id: node.id, - data: { status: result.status === 'error' ? 'error' : 'success' }, - }); - }); - } catch (error) { - console.error('Failed to execute workflow:', error); + // Use capture phase only to ensure we can intercept before other handlers + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [handleSaveShortcut, handleRunShortcut]); - // Mark all nodes as error - nodes.forEach((node) => { - updateNodeData({ id: node.id, data: { status: 'error' } }); - }); - } finally { - setIsExecuting(false); - } - }, [isExecuting, nodes, isGenerating, currentWorkflowId, setIsExecuting, updateNodeData]); + // Cleanup polling interval on unmount + useEffect( + () => () => { + if (executionPollingIntervalRef.current) { + clearInterval(executionPollingIntervalRef.current); + } + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + } + }, + [] + ); + // Poll for selected execution status useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Cmd+S or Ctrl+S to save - if ((e.metaKey || e.ctrlKey) && e.key === 's') { - e.preventDefault(); - handleSave(); + // Clear existing interval if any + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + + // If no execution is selected or it's the currently running one, don't poll + if (!selectedExecutionId) { + // Reset all node statuses when no execution is selected + for (const node of nodesRef.current) { + updateNodeData({ id: node.id, data: { status: "idle" } }); } - // Cmd+Enter or Ctrl+Enter to run - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - handleRun(); + return; + } + + // Start polling for the selected execution + const pollSelectedExecution = async () => { + try { + const statusData = + await api.workflow.getExecutionStatus(selectedExecutionId); + + // Update node statuses based on the execution logs + for (const nodeStatus of statusData.nodeStatuses) { + updateNodeData({ + id: nodeStatus.nodeId, + data: { + status: nodeStatus.status as + | "idle" + | "running" + | "success" + | "error", + }, + }); + } + + // Stop polling if execution is complete + if ( + statusData.status !== "running" && + selectedExecutionPollingIntervalRef.current + ) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + } catch (error) { + console.error("Failed to poll selected execution status:", error); + // Clear polling on error + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleSave, handleRun]); + // Poll immediately and then every 500ms + pollSelectedExecution(); + const pollInterval = setInterval(pollSelectedExecution, 500); + selectedExecutionPollingIntervalRef.current = pollInterval; - if (isLoading) { - return ( -
-
-
Loading workflow...
-
Please wait
-
-
- ); - } + return () => { + if (selectedExecutionPollingIntervalRef.current) { + clearInterval(selectedExecutionPollingIntervalRef.current); + selectedExecutionPollingIntervalRef.current = null; + } + }; + }, [selectedExecutionId, updateNodeData]); return ( -
- -
-
- - - - -
- -
+
+ {/* Workflow not found overlay */} + {workflowNotFound && ( +
+
+

Workflow Not Found

+

+ The workflow you're looking for doesn't exist or has been deleted. +

+ +
+
+ )} + + {/* Expand button when panel is collapsed */} + {!isMobile && panelCollapsed && ( + + )} + + {/* Right panel overlay (desktop only) */} + {!isMobile && ( +
+ {/* Resize handle with collapse button */} + {/* biome-ignore lint/a11y/useSemanticElements: custom resize handle */} +
+ {/* Hover indicator */} +
+ {/* Collapse button - hidden while resizing */} + {!(isDraggingResize || panelCollapsed) && ( + + )} +
+ +
+ )}
); -} +}; -export default function WorkflowPage({ params }: { params: Promise<{ workflowId: string }> }) { - return ( - - - - - - ); -} +const WorkflowPage = ({ params }: WorkflowPageProps) => ( + +); + +export default WorkflowPage; diff --git a/app/workflows/page.tsx b/app/workflows/page.tsx index 0dd783fa..403306bb 100644 --- a/app/workflows/page.tsx +++ b/app/workflows/page.tsx @@ -1,15 +1,38 @@ -'use client'; +"use client"; -import { Provider } from 'jotai'; -import { AuthProvider } from '@/components/auth/auth-provider'; -import { WorkflowsList } from '@/components/workflows/workflows-list'; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { api } from "@/lib/api-client"; export default function WorkflowsPage() { - return ( - - - - - - ); + const router = useRouter(); + + useEffect(() => { + const redirectToWorkflow = async () => { + try { + const workflows = await api.workflow.getAll(); + // Filter out the auto-save workflow + const filtered = workflows.filter((w) => w.name !== "__current__"); + + if (filtered.length > 0) { + // Sort by updatedAt descending to get most recent + const mostRecent = filtered.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + )[0]; + router.replace(`/workflows/${mostRecent.id}`); + } else { + // No workflows, redirect to homepage + router.replace("/"); + } + } catch (error) { + console.error("Failed to load workflows:", error); + router.replace("/"); + } + }; + + redirectToWorkflow(); + }, [router]); + + return null; } diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 00000000..c98cd851 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["ultracite/core", "ultracite/react", "ultracite/next"], + "files": { + "includes": [ + "**/*", + "!components/ui", + "!components/ai-elements", + "!lib/utils.ts", + "!hooks/use-mobile.ts", + "!plugins" + ] + } +} diff --git a/components/ai-elements/canvas.tsx b/components/ai-elements/canvas.tsx new file mode 100644 index 00000000..1312424e --- /dev/null +++ b/components/ai-elements/canvas.tsx @@ -0,0 +1,29 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => { + return ( + + + {children} + + ); +}; diff --git a/components/ai-elements/connection.tsx b/components/ai-elements/connection.tsx new file mode 100644 index 00000000..bb73356d --- /dev/null +++ b/components/ai-elements/connection.tsx @@ -0,0 +1,28 @@ +import type { ConnectionLineComponent } from "@xyflow/react"; + +const HALF = 0.5; + +export const Connection: ConnectionLineComponent = ({ + fromX, + fromY, + toX, + toY, +}) => ( + + + + +); diff --git a/components/ai-elements/controls.tsx b/components/ai-elements/controls.tsx new file mode 100644 index 00000000..24622a86 --- /dev/null +++ b/components/ai-elements/controls.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useReactFlow } from "@xyflow/react"; +import { ZoomIn, ZoomOut, Maximize2, MapPin, MapPinXInside } from "lucide-react"; +import { useAtom } from "jotai"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { showMinimapAtom } from "@/lib/workflow-store"; + +export const Controls = () => { + const { zoomIn, zoomOut, fitView } = useReactFlow(); + const [showMinimap, setShowMinimap] = useAtom(showMinimapAtom); + + const handleZoomIn = () => { + zoomIn(); + }; + + const handleZoomOut = () => { + zoomOut(); + }; + + const handleFitView = () => { + fitView({ padding: 0.2, duration: 300 }); + }; + + const handleToggleMinimap = () => { + setShowMinimap(!showMinimap); + }; + + return ( + + + + + + + ); +}; diff --git a/components/ai-elements/edge.tsx b/components/ai-elements/edge.tsx new file mode 100644 index 00000000..9d8efc39 --- /dev/null +++ b/components/ai-elements/edge.tsx @@ -0,0 +1,147 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from "@xyflow/react"; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + selected, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? "target" : "source"; + + const handle = node.internals.handleBounds?.[handleType]?.find( + (h) => h.position === handlePosition + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, style, selected }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/components/ai-elements/node.tsx b/components/ai-elements/node.tsx index 629c57b9..21b52005 100644 --- a/components/ai-elements/node.tsx +++ b/components/ai-elements/node.tsx @@ -6,28 +6,33 @@ import { CardFooter, CardHeader, CardTitle, -} from '@/components/ui/card'; -import { cn } from '@/lib/utils'; -import { Handle, Position } from '@xyflow/react'; -import type { ComponentProps } from 'react'; +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Handle, Position } from "@xyflow/react"; +import type { ComponentProps } from "react"; +import { AnimatedBorder } from "@/components/ui/animated-border"; export type NodeProps = ComponentProps & { handles: { target: boolean; source: boolean; }; + status?: "idle" | "running" | "success" | "error"; }; -export const Node = ({ handles, className, ...props }: NodeProps) => ( +export const Node = ({ handles, className, status, ...props }: NodeProps) => ( - {handles.target && } - {handles.source && } + {status === "running" && } + {handles.target && } + {handles.source && } {props.children} ); @@ -36,7 +41,7 @@ export type NodeHeaderProps = ComponentProps; export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( ); @@ -47,7 +52,9 @@ export const NodeTitle = (props: NodeTitleProps) => ; export type NodeDescriptionProps = ComponentProps; -export const NodeDescription = (props: NodeDescriptionProps) => ; +export const NodeDescription = (props: NodeDescriptionProps) => ( + +); export type NodeActionProps = ComponentProps; @@ -56,11 +63,14 @@ export const NodeAction = (props: NodeActionProps) => ; export type NodeContentProps = ComponentProps; export const NodeContent = ({ className, ...props }: NodeContentProps) => ( - + ); export type NodeFooterProps = ComponentProps; export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( - + ); diff --git a/components/ai-elements/panel.tsx b/components/ai-elements/panel.tsx new file mode 100644 index 00000000..8fbcfdfb --- /dev/null +++ b/components/ai-elements/panel.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { Panel as PanelPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +type PanelProps = ComponentProps; + +export const Panel = ({ className, ...props }: PanelProps) => ( + +); diff --git a/components/ai-elements/prompt.tsx b/components/ai-elements/prompt.tsx new file mode 100644 index 00000000..58068406 --- /dev/null +++ b/components/ai-elements/prompt.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useReactFlow } from "@xyflow/react"; +import { useAtom, useAtomValue } from "jotai"; +import { ArrowUp } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api-client"; +import { + currentWorkflowIdAtom, + currentWorkflowNameAtom, + edgesAtom, + isGeneratingAtom, + nodesAtom, + selectedNodeAtom, +} from "@/lib/workflow-store"; + +type AIPromptProps = { + workflowId?: string; + onWorkflowCreated?: (workflowId: string) => void; +}; + +export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { + const [isGenerating, setIsGenerating] = useAtom(isGeneratingAtom); + const [prompt, setPrompt] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + const nodes = useAtomValue(nodesAtom); + const [edges, setEdges] = useAtom(edgesAtom); + const [_nodes, setNodes] = useAtom(nodesAtom); + const [_currentWorkflowId, setCurrentWorkflowId] = useAtom(currentWorkflowIdAtom); + const [_currentWorkflowName, setCurrentWorkflowName] = useAtom(currentWorkflowNameAtom); + const [_selectedNodeId, setSelectedNodeId] = useAtom(selectedNodeAtom); + const { fitView } = useReactFlow(); + + // Filter out placeholder "add" nodes to get real nodes + const realNodes = nodes.filter((node) => node.type !== "add"); + const hasNodes = realNodes.length > 0; + + // Focus input when Cmd/Ctrl + K is pressed + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + inputRef.current?.focus(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const handleFocus = () => { + setIsExpanded(true); + setIsFocused(true); + }; + + const handleBlur = (e: React.FocusEvent) => { + // Don't collapse if focus is moving to another element within the container + if (containerRef.current?.contains(e.relatedTarget as Node)) { + return; + } + setIsFocused(false); + if (!prompt.trim()) { + setIsExpanded(false); + } + }; + + const handleGenerate = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + + if (!prompt.trim() || isGenerating) { + return; + } + + setIsGenerating(true); + + try { + // Send existing workflow data for context when modifying + const existingWorkflow = hasNodes + ? { nodes: realNodes, edges, name: _currentWorkflowName } + : undefined; + + console.log("[AI Prompt] Generating workflow"); + console.log("[AI Prompt] Has nodes:", hasNodes); + console.log("[AI Prompt] Sending existing workflow:", !!existingWorkflow); + if (existingWorkflow) { + console.log( + "[AI Prompt] Existing workflow:", + existingWorkflow.nodes.length, + "nodes,", + existingWorkflow.edges.length, + "edges" + ); + } + + // Use streaming API with incremental updates + const workflowData = await api.ai.generateStream( + prompt, + (partialData) => { + // Update UI incrementally with animated edges + const edgesWithAnimatedType = (partialData.edges || []).map((edge) => ({ + ...edge, + type: "animated", + })); + + // Validate: ensure only ONE trigger node exists + const triggerNodes = (partialData.nodes || []).filter( + (node) => node.data?.type === "trigger" + ); + + let validEdges = edgesWithAnimatedType; + + if (triggerNodes.length > 1) { + // Keep only the first trigger and all non-trigger nodes + const firstTrigger = triggerNodes[0]; + const nonTriggerNodes = (partialData.nodes || []).filter( + (node) => node.data?.type !== "trigger" + ); + partialData.nodes = [firstTrigger, ...nonTriggerNodes]; + + // Remove edges connected to removed triggers + const removedTriggerIds = triggerNodes.slice(1).map((n) => n.id); + validEdges = edgesWithAnimatedType.filter( + (edge) => + !removedTriggerIds.includes(edge.source) && + !removedTriggerIds.includes(edge.target) + ); + } + + // Update the canvas incrementally + setNodes(partialData.nodes || []); + setEdges(validEdges); + if (partialData.name) { + setCurrentWorkflowName(partialData.name); + } + // Fit view after each update to keep all nodes visible + setTimeout(() => { + fitView({ padding: 0.2, duration: 200 }); + }, 0); + }, + existingWorkflow + ); + + console.log("[AI Prompt] Received final workflow data"); + console.log("[AI Prompt] Nodes:", workflowData.nodes?.length || 0); + console.log("[AI Prompt] Edges:", workflowData.edges?.length || 0); + + // Use edges from workflow data with animated type + const finalEdges = (workflowData.edges || []).map((edge) => ({ + ...edge, + type: "animated", + })); + + // Validate: check for blank/incomplete nodes + console.log("[AI Prompt] Validating nodes:", workflowData.nodes); + const incompleteNodes = (workflowData.nodes || []).filter((node) => { + const nodeType = node.data?.type; + const config = node.data?.config || {}; + + console.log(`[AI Prompt] Checking node ${node.id}:`, { + type: nodeType, + config, + hasActionType: !!config.actionType, + hasTriggerType: !!config.triggerType, + }); + + // Check trigger nodes + if (nodeType === "trigger") { + return !config.triggerType; + } + + // Check action nodes + if (nodeType === "action") { + return !config.actionType; + } + + // Allow other node types (condition, transform) without strict validation + return false; + }); + + if (incompleteNodes.length > 0) { + console.error( + "[AI Prompt] AI generated incomplete nodes:", + incompleteNodes + ); + console.error( + "[AI Prompt] Full workflow data:", + JSON.stringify(workflowData, null, 2) + ); + throw new Error( + `Cannot create workflow: The AI tried to create ${incompleteNodes.length} incomplete node(s). The requested action type may not be supported. Please try a different description using supported actions: Send Email, Send Slack Message, Create Ticket, Database Query, HTTP Request, Generate Text, Generate Image, Scrape, or Search.` + ); + } + + // If no workflowId, create a new workflow + if (!workflowId) { + const newWorkflow = await api.workflow.create({ + name: workflowData.name || "AI Generated Workflow", + description: workflowData.description || "", + nodes: workflowData.nodes || [], + edges: finalEdges, + }); + + // State already updated by streaming callback + setCurrentWorkflowId(newWorkflow.id); + + toast.success("Created workflow"); + + // Notify parent component to redirect + if (onWorkflowCreated) { + onWorkflowCreated(newWorkflow.id); + } + } else { + setCurrentWorkflowId(workflowId); + + console.log("[AI Prompt] Updating existing workflow:", workflowId); + console.log("[AI Prompt] Has existingWorkflow context:", !!existingWorkflow); + + // State already updated by streaming callback + if (existingWorkflow) { + console.log("[AI Prompt] REPLACING workflow with AI response"); + console.log( + "[AI Prompt] Replacing", + realNodes.length, + "nodes with", + workflowData.nodes?.length || 0, + "nodes" + ); + } else { + console.log("[AI Prompt] Setting workflow for empty canvas"); + + toast.success("Generated workflow"); + } + + const selectedNode = workflowData.nodes?.find( + (n: { selected?: boolean }) => n.selected + ); + if (selectedNode) { + setSelectedNodeId(selectedNode.id); + } + + // Save the updated workflow + await api.workflow.update(workflowId, { + name: workflowData.name, + description: workflowData.description, + nodes: workflowData.nodes, + edges: finalEdges, + }); + } + + // Clear and close + setPrompt(""); + setIsExpanded(false); + setIsFocused(false); + inputRef.current?.blur(); + } catch (error) { + console.error("Failed to generate workflow:", error); + toast.error("Failed to generate workflow"); + } finally { + setIsGenerating(false); + } + }, + [ + prompt, + isGenerating, + workflowId, + hasNodes, + nodes, + edges, + setIsGenerating, + setCurrentWorkflowId, + setNodes, + setEdges, + setCurrentWorkflowName, + setSelectedNodeId, + onWorkflowCreated, + fitView, + ] + ); + + return ( + <> + {/* Always visible prompt input */} +
+
{ + // Focus textarea when clicking anywhere in the form (including padding) + if (e.target === e.currentTarget || (e.target as HTMLElement).tagName !== 'BUTTON') { + inputRef.current?.focus(); + } + }} + onMouseDown={(e) => { + // Prevent textarea from losing focus when clicking form padding + if (e.target === e.currentTarget) { + e.preventDefault(); + } + }} + onSubmit={handleGenerate} + role="search" + > + {isGenerating && prompt ? ( + + {prompt} + + ) : ( +