From e34ef796a79ff0a397c4cc4d3c6377e76de5c031 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 19:56:15 -0600 Subject: [PATCH 001/340] Enable sign in on vercel preview despite dynamic url (#2) Co-authored-by: Chris Tate --- lib/auth.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/auth.ts b/lib/auth.ts index 5190984a..d961be82 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,7 +3,22 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { db } from './db'; import * as schema from './db/schema'; +// Determine the base URL for authentication +// This supports Vercel Preview deployments with dynamic URLs +function getBaseURL() { + // Check if we're on Vercel + if (process.env.VERCEL_URL) { + // VERCEL_URL doesn't include protocol, so add it + // Use https for Vercel deployments (both production and preview) + return `https://${process.env.VERCEL_URL}`; + } + + // For local development + return process.env.BETTER_AUTH_URL || 'http://localhost:3000'; +} + export const auth = betterAuth({ + baseURL: getBaseURL(), database: drizzleAdapter(db, { provider: 'pg', schema, From 02c0208c39ded78f2c3929f2740622861ebcb954 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 20:26:40 -0600 Subject: [PATCH 002/340] Improve mobile support for workflow detail view (#3) * Improve workflow detail ui: header with back and name; show pane on tap * Limit two-line requirement to workflow page * Align avatar on same line as back button and workflow name; ellipsize --------- Co-authored-by: Chris Tate --- components/app-header.tsx | 54 ++++++ components/workflow/node-config-panel.tsx | 196 ++++++++++++---------- components/workflow/workflow-toolbar.tsx | 8 +- 3 files changed, 164 insertions(+), 94 deletions(-) diff --git a/components/app-header.tsx b/components/app-header.tsx index 838874cb..4738e177 100644 --- a/components/app-header.tsx +++ b/components/app-header.tsx @@ -12,6 +12,7 @@ interface AppHeaderProps { onBack?: () => void; actions?: React.ReactNode; disableTitleLink?: boolean; + useMobileTwoLineLayout?: boolean; } export function AppHeader({ @@ -20,6 +21,7 @@ export function AppHeader({ onBack, actions, disableTitleLink = false, + useMobileTwoLineLayout = false, }: AppHeaderProps) { const router = useRouter(); @@ -31,6 +33,58 @@ export function AppHeader({ } }; + // Two-line mobile layout (for workflow page) + if (useMobileTwoLineLayout) { + return ( +
+ {/* Mobile: Two-line layout */} +
+ {/* First line: Back button + Title + Avatar */} +
+ {showBackButton && ( + + )} + {disableTitleLink ? ( +
{title}
+ ) : ( + +

{title}

+ + )} + +
+ {/* Second line: Actions */} +
{actions}
+
+ + {/* Desktop: Single line layout */} +
+
+ {showBackButton && ( + + )} + {disableTitleLink ? ( +
{title}
+ ) : ( + +

{title}

+ + )} +
+
+ {actions} + +
+
+
+ ); + } + + // Standard single-line layout (for all other pages) return (
diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 112d6643..66738c11 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -28,7 +28,7 @@ export function NodeConfigPanel() { if (!selectedNode) { return ( - + Properties @@ -63,103 +63,113 @@ export function NodeConfigPanel() { }; return ( - - - Properties - - - -
- - handleUpdateLabel(e.target.value)} - disabled={isGenerating} - /> -
- -
- - handleUpdateDescription(e.target.value)} - placeholder="Optional description" - disabled={isGenerating} - /> -
- - {/* Show available outputs from previous nodes */} - {(selectedNode.data.type === 'action' || - selectedNode.data.type === 'condition' || - selectedNode.data.type === 'transform') && } - -
- + <> + {/* Mobile overlay backdrop */} + - -
- -
- - + +
+ +
+ + + ); } diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index 8aca6d97..3c7df7ce 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -277,7 +277,13 @@ export function WorkflowToolbar({}: { workflowId?: string }) { return ( <> - + {/* Clear Workflow Dialog */} From 1bfd6db58f730a247da3f8b36f1e1bbc05ac1e9c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 21:01:17 -0600 Subject: [PATCH 003/340] Use dvh for sign in page scroll height on mobile (#4) Co-authored-by: Chris Tate --- app/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index 18f6a770..120486eb 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -44,7 +44,7 @@ export default function LoginPage() { }; return ( -
+
From 76f1e2d07751762d1d27fecc313bcd999f65de4c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 21:23:02 -0600 Subject: [PATCH 004/340] better auth --- app/login/page.tsx | 91 ++++++++++++------------ app/page.tsx | 5 +- components/workflows/user-menu.tsx | 9 +++ components/workflows/workflow-prompt.tsx | 12 +++- components/workflows/workflows-list.tsx | 35 +++++---- 5 files changed, 88 insertions(+), 64 deletions(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index 120486eb..da7e5821 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -6,7 +6,6 @@ import { signIn, signUp } from '@/lib/auth-client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'; export default function LoginPage() { const [isSignUp, setIsSignUp] = useState(false); @@ -45,56 +44,54 @@ export default function LoginPage() { return (
- - - +
+
+

{isSignUp ? 'Create Account' : 'Sign In'} - - - -
- {isSignUp && ( -
- - setName(e.target.value)} - required - placeholder="John Doe" - /> -
- )} +

+
+ + {isSignUp && (
- + setEmail(e.target.value)} + id="name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} required - placeholder="you@example.com" + placeholder="John Doe" />
-
- - setPassword(e.target.value)} - required - placeholder="••••••••" - /> -
- {error &&
{error}
} - - - - + )} +
+ + setEmail(e.target.value)} + required + placeholder="you@example.com" + /> +
+
+ + setPassword(e.target.value)} + required + placeholder="••••••••" + /> +
+ {error &&
{error}
} + + +
- - +
+
); } diff --git a/app/page.tsx b/app/page.tsx index 153535c3..44fd0b19 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,15 +1,12 @@ '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 ( - - - + ); } diff --git a/components/workflows/user-menu.tsx b/components/workflows/user-menu.tsx index 4989d2aa..5dfaa013 100644 --- a/components/workflows/user-menu.tsx +++ b/components/workflows/user-menu.tsx @@ -44,6 +44,15 @@ export function UserMenu() { return 'U'; }; + // Show Sign In button if user is not logged in + if (!session) { + return ( + + ); + } + return ( diff --git a/components/workflows/workflow-prompt.tsx b/components/workflows/workflow-prompt.tsx index 0b4af2dd..2fd9d281 100644 --- a/components/workflows/workflow-prompt.tsx +++ b/components/workflows/workflow-prompt.tsx @@ -6,17 +6,27 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Loader2, ArrowUp } from 'lucide-react'; import { workflowApi } from '@/lib/workflow-api'; +import { useSession } from '@/lib/auth-client'; +import { toast } from 'sonner'; export function WorkflowPrompt() { const [prompt, setPrompt] = useState(''); const [isGenerating, setIsGenerating] = useState(false); const router = useRouter(); const textareaRef = useRef(null); + const { data: session } = useSession(); const handleGenerate = async (e: React.FormEvent) => { e.preventDefault(); if (!prompt.trim() || isGenerating) return; + // Check if user is logged in + if (!session) { + // Redirect to login page + router.push('/login'); + return; + } + setIsGenerating(true); try { // Create empty workflow first @@ -35,7 +45,7 @@ export function WorkflowPrompt() { router.push(`/workflows/${newWorkflow.id}?generating=true`); } catch (error) { console.error('Failed to create workflow:', error); - alert('Failed to create workflow. Please try again.'); + toast.error('Failed to create workflow. Please try again.'); setIsGenerating(false); } }; diff --git a/components/workflows/workflows-list.tsx b/components/workflows/workflows-list.tsx index 1dca459a..02b20bd7 100644 --- a/components/workflows/workflows-list.tsx +++ b/components/workflows/workflows-list.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Plus, Clock, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -18,6 +18,8 @@ import { workflowApi, type SavedWorkflow } from '@/lib/workflow-api'; import { WorkflowPrompt } from './workflow-prompt'; import { AppHeader } from '@/components/app-header'; import { getRelativeTime } from '@/lib/utils/time'; +import { useSession } from '@/lib/auth-client'; +import { toast } from 'sonner'; interface WorkflowsListProps { limit?: number; @@ -36,12 +38,16 @@ export function WorkflowsList({ const [deleting, setDeleting] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const router = useRouter(); + const { data: session } = useSession(); - useEffect(() => { - loadWorkflows(); - }, []); + const loadWorkflows = useCallback(async () => { + // Only load workflows if user is logged in + if (!session) { + setLoading(false); + setWorkflows([]); + return; + } - const loadWorkflows = async () => { try { setLoading(true); const data = await workflowApi.getAll(); @@ -53,11 +59,21 @@ export function WorkflowsList({ } finally { setLoading(false); } - }; + }, [session]); + + useEffect(() => { + loadWorkflows(); + }, [loadWorkflows]); const displayedWorkflows = limit ? workflows.slice(0, limit) : workflows; const handleNewWorkflow = async () => { + // Check if user is logged in + if (!session) { + router.push('/login'); + return; + } + try { const newWorkflow = await workflowApi.create({ name: 'Untitled', @@ -68,7 +84,7 @@ export function WorkflowsList({ router.push(`/workflows/${newWorkflow.id}`); } catch (error) { console.error('Failed to create workflow:', error); - alert('Failed to create workflow. Please try again.'); + toast.error('Failed to create workflow. Please try again.'); } }; @@ -239,11 +255,6 @@ export function WorkflowsList({ {getRelativeTime(workflow.updatedAt)}
- {workflow.description && ( -
- {workflow.description} -
- )}
))} From af738441c3f4cf0ce8890b55239e5acfd4ab7be6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 21:25:12 -0600 Subject: [PATCH 005/340] resize pane --- components/workflow/node-config-panel.tsx | 85 ++++++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 66738c11..4a254111 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -1,6 +1,7 @@ 'use client'; import { useAtom, useSetAtom } from 'jotai'; +import { useState, useEffect, useRef } from 'react'; import { selectedNodeAtom, nodesAtom, @@ -17,18 +18,86 @@ import { TriggerConfig } from './config/trigger-config'; import { ActionConfig } from './config/action-config'; import { AvailableOutputs } from './available-outputs'; +const MIN_WIDTH = 280; +const MAX_WIDTH = 600; +const DEFAULT_WIDTH = 320; + export function NodeConfigPanel() { const [selectedNodeId, setSelectedNodeId] = useAtom(selectedNodeAtom); const [nodes] = useAtom(nodesAtom); const [isGenerating] = useAtom(isGeneratingAtom); const updateNodeData = useSetAtom(updateNodeDataAtom); const deleteNode = useSetAtom(deleteNodeAtom); + const [panelWidth, setPanelWidth] = useState(() => { + // Load saved width from localStorage on mount + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('nodeConfigPanelWidth'); + if (saved) { + const width = parseInt(saved, 10); + if (width >= MIN_WIDTH && width <= MAX_WIDTH) { + return width; + } + } + } + return DEFAULT_WIDTH; + }); + const [isResizing, setIsResizing] = useState(false); + const panelRef = useRef(null); const selectedNode = nodes.find((node) => node.id === selectedNodeId); + // Handle resize + useEffect(() => { + if (!isResizing) return; + + // Prevent text selection while resizing + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (e: MouseEvent) => { + if (!panelRef.current) return; + const panelRect = panelRef.current.getBoundingClientRect(); + const newWidth = panelRect.right - e.clientX; + const clampedWidth = Math.min(Math.max(newWidth, MIN_WIDTH), MAX_WIDTH); + setPanelWidth(clampedWidth); + }; + + const handleMouseUp = () => { + setIsResizing(false); + localStorage.setItem('nodeConfigPanelWidth', panelWidth.toString()); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + }; + }, [isResizing, panelWidth]); + + const handleResizeStart = (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }; + if (!selectedNode) { return ( - + + {/* Resize handle */} +
Properties @@ -71,8 +140,18 @@ export function NodeConfigPanel() { aria-hidden="true" /> - {/* Properties panel - Mobile: Fixed sidebar, Desktop: Normal sidebar */} - + {/* Properties panel - Mobile: Fixed sidebar, Desktop: Resizable sidebar */} + + {/* Resize handle - only visible on desktop */} +
Properties
); } diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 4a254111..055949a1 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -28,24 +28,24 @@ export function NodeConfigPanel() { const [isGenerating] = useAtom(isGeneratingAtom); const updateNodeData = useSetAtom(updateNodeDataAtom); const deleteNode = useSetAtom(deleteNodeAtom); - const [panelWidth, setPanelWidth] = useState(() => { - // Load saved width from localStorage on mount - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('nodeConfigPanelWidth'); - if (saved) { - const width = parseInt(saved, 10); - if (width >= MIN_WIDTH && width <= MAX_WIDTH) { - return width; - } - } - } - return DEFAULT_WIDTH; - }); + const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH); const [isResizing, setIsResizing] = useState(false); const panelRef = useRef(null); const selectedNode = nodes.find((node) => node.id === selectedNodeId); + // Load saved width from localStorage after mount to avoid hydration mismatch + useEffect(() => { + const saved = localStorage.getItem('nodeConfigPanelWidth'); + if (saved) { + const width = parseInt(saved, 10); + if (width >= MIN_WIDTH && width <= MAX_WIDTH) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setPanelWidth(width); + } + } + }, []); + // Handle resize useEffect(() => { if (!isResizing) return; @@ -90,7 +90,7 @@ export function NodeConfigPanel() { {/* Resize handle */}
{ URL: {nodeData.config?.endpoint as string}
)} - {nodeData.status && ( -
- Status: {nodeData.status} -
- )}
diff --git a/components/workflow/nodes/condition-node.tsx b/components/workflow/nodes/condition-node.tsx index d04bf398..e1bbc94b 100644 --- a/components/workflow/nodes/condition-node.tsx +++ b/components/workflow/nodes/condition-node.tsx @@ -28,25 +28,8 @@ export const ConditionNode = memo(({ data, selected }: NodeProps) => { {nodeData.description && {nodeData.description}} -
-
- Condition: {(nodeData.config?.condition as string) || 'If true'} -
- {nodeData.status && ( -
- Status: {nodeData.status} -
- )} +
+ Condition: {(nodeData.config?.condition as string) || 'If true'}
diff --git a/components/workflow/nodes/transform-node.tsx b/components/workflow/nodes/transform-node.tsx index f0b6c91f..c9bf88ce 100644 --- a/components/workflow/nodes/transform-node.tsx +++ b/components/workflow/nodes/transform-node.tsx @@ -28,25 +28,8 @@ export const TransformNode = memo(({ data, selected }: NodeProps) => { {nodeData.description && {nodeData.description}} -
-
- Transform: {(nodeData.config?.transformType as string) || 'Map Data'} -
- {nodeData.status && ( -
- Status: {nodeData.status} -
- )} +
+ Transform: {(nodeData.config?.transformType as string) || 'Map Data'}
diff --git a/components/workflow/nodes/trigger-node.tsx b/components/workflow/nodes/trigger-node.tsx index e390a7d1..db283a43 100644 --- a/components/workflow/nodes/trigger-node.tsx +++ b/components/workflow/nodes/trigger-node.tsx @@ -28,25 +28,8 @@ export const TriggerNode = memo(({ data, selected }: NodeProps) => { {nodeData.description && {nodeData.description}} -
-
- Trigger Type: {(nodeData.config?.triggerType as string) || 'Manual'} -
- {nodeData.status && ( -
- Status: {nodeData.status} -
- )} +
+ Trigger Type: {(nodeData.config?.triggerType as string) || 'Manual'}
diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index 5ebc23a7..f152576c 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useAtom, useSetAtom } from 'jotai'; import { ReactFlow, @@ -12,6 +12,7 @@ import { type OnConnect, BackgroundVariant, useReactFlow, + type Viewport, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; @@ -23,6 +24,7 @@ import { selectedNodeAtom, isGeneratingAtom, addNodeAtom, + currentWorkflowIdAtom, type WorkflowNode, type WorkflowNodeType, } from '@/lib/workflow-store'; @@ -68,11 +70,12 @@ export function WorkflowCanvas() { const [nodes] = useAtom(nodesAtom); const [edges, setEdges] = useAtom(edgesAtom); const [isGenerating] = useAtom(isGeneratingAtom); + const [currentWorkflowId] = useAtom(currentWorkflowIdAtom); const onNodesChange = useSetAtom(onNodesChangeAtom); const onEdgesChange = useSetAtom(onEdgesChangeAtom); const setSelectedNode = useSetAtom(selectedNodeAtom); const addNode = useSetAtom(addNodeAtom); - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, setViewport } = useReactFlow(); const [menu, setMenu] = useState<{ id: string; @@ -82,6 +85,45 @@ export function WorkflowCanvas() { } | null>(null); const connectingNodeId = useRef(null); const menuJustOpened = useRef(false); + const [defaultViewport, setDefaultViewport] = useState(undefined); + const viewportInitialized = useRef(false); + + // Load saved viewport when workflow changes + useEffect(() => { + if (!currentWorkflowId) return; + + const saved = localStorage.getItem(`workflow-viewport-${currentWorkflowId}`); + if (saved) { + try { + const viewport = JSON.parse(saved) as Viewport; + // eslint-disable-next-line react-hooks/set-state-in-effect + setDefaultViewport(viewport); + // Set viewport after a brief delay to ensure ReactFlow is ready + setTimeout(() => { + setViewport(viewport, { duration: 0 }); + viewportInitialized.current = true; + }, 100); + } catch (error) { + console.error('Failed to load viewport:', error); + viewportInitialized.current = true; + } + } else { + setDefaultViewport(undefined); + // Allow saving viewport after fitView completes + setTimeout(() => { + viewportInitialized.current = true; + }, 500); + } + }, [currentWorkflowId, setViewport]); + + // Save viewport changes + const onMoveEnd = useCallback( + (_event: MouseEvent | TouchEvent | null, viewport: Viewport) => { + if (!currentWorkflowId || !viewportInitialized.current) return; + localStorage.setItem(`workflow-viewport-${currentWorkflowId}`, JSON.stringify(viewport)); + }, + [currentWorkflowId] + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeTypes = useMemo>>( @@ -236,9 +278,11 @@ export function WorkflowCanvas() { onConnectEnd={isGenerating ? undefined : onConnectEnd} onNodeClick={isGenerating ? undefined : onNodeClick} onPaneClick={onPaneClick} + onMoveEnd={onMoveEnd} nodeTypes={nodeTypes} connectionMode={ConnectionMode.Loose} - fitView + defaultViewport={defaultViewport} + fitView={!defaultViewport} className="bg-background" nodesDraggable={!isGenerating} nodesConnectable={!isGenerating} From f2995822020332a56d9600919e7995195377ca7c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 22:03:21 -0600 Subject: [PATCH 007/340] fix node --- app/globals.css | 1 - components/ai-elements/node.tsx | 8 ++-- components/workflow/node-library.tsx | 30 ++++++++----- components/workflow/nodes/action-node.tsx | 46 +++++++++++++------- components/workflow/nodes/condition-node.tsx | 22 ++++++---- components/workflow/nodes/transform-node.tsx | 22 ++++++---- components/workflow/nodes/trigger-node.tsx | 22 ++++++---- components/workflow/workflow-canvas.tsx | 24 +++++----- 8 files changed, 110 insertions(+), 65 deletions(-) diff --git a/app/globals.css b/app/globals.css index 488513e0..e502e168 100644 --- a/app/globals.css +++ b/app/globals.css @@ -181,7 +181,6 @@ /* Node Width */ .node-container { width: 280px; - min-height: 100px; } /* Remove ReactFlow default node shadow */ diff --git a/components/ai-elements/node.tsx b/components/ai-elements/node.tsx index 629c57b9..1274087c 100644 --- a/components/ai-elements/node.tsx +++ b/components/ai-elements/node.tsx @@ -21,7 +21,7 @@ export type NodeProps = ComponentProps & { export const Node = ({ handles, className, ...props }: NodeProps) => ( ; export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( ); @@ -56,11 +56,11 @@ 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/workflow/node-library.tsx b/components/workflow/node-library.tsx index 6b07b973..85cdd831 100644 --- a/components/workflow/node-library.tsx +++ b/components/workflow/node-library.tsx @@ -9,29 +9,37 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; const nodeTemplates = [ { type: 'trigger' as WorkflowNodeType, - label: 'Trigger', - description: 'Start your workflow', + label: '', + description: '', + displayLabel: 'Trigger', + displayDescription: 'Start your workflow', icon: PlayCircle, defaultConfig: { triggerType: 'Manual' }, }, { type: 'action' as WorkflowNodeType, - label: 'Action', - description: 'Perform an action', + label: '', + description: '', + displayLabel: 'Action', + displayDescription: 'Perform an action', icon: Zap, - defaultConfig: { actionType: 'HTTP Request', endpoint: 'https://api.example.com' }, + defaultConfig: { actionType: 'HTTP Request' }, }, { type: 'condition' as WorkflowNodeType, - label: 'Condition', - description: 'Branch your workflow', + label: '', + description: '', + displayLabel: 'Condition', + displayDescription: 'Branch your workflow', icon: GitBranch, defaultConfig: { condition: 'If true' }, }, { type: 'transform' as WorkflowNodeType, - label: 'Transform', - description: 'Transform data', + label: '', + description: '', + displayLabel: 'Transform', + displayDescription: 'Transform data', icon: Shuffle, defaultConfig: { transformType: 'Map Data' }, }, @@ -83,8 +91,8 @@ export function NodeLibrary() {
-
{template.label}
-
{template.description}
+
{template.displayLabel}
+
{template.displayDescription}
diff --git a/components/workflow/nodes/action-node.tsx b/components/workflow/nodes/action-node.tsx index 44422560..ecc26685 100644 --- a/components/workflow/nodes/action-node.tsx +++ b/components/workflow/nodes/action-node.tsx @@ -12,33 +12,49 @@ import { import { Zap } from 'lucide-react'; import type { WorkflowNodeData } from '@/lib/workflow-store'; +// Helper to get integration name from action type +const getIntegrationFromActionType = (actionType: string): string => { + const integrationMap: Record = { + 'Send Email': 'Resend', + 'Send Slack Message': 'Slack', + 'Create Ticket': 'Linear', + 'Find Issues': 'Linear', + 'HTTP Request': 'System', + 'Database Query': 'System', + }; + return integrationMap[actionType] || 'System'; +}; + export const ActionNode = memo(({ data, selected }: NodeProps) => { const nodeData = data as WorkflowNodeData; if (!nodeData) return null; + + const actionType = (nodeData.config?.actionType as string) || 'HTTP Request'; + const displayTitle = nodeData.label || actionType; + const displayDescription = nodeData.description || getIntegrationFromActionType(actionType); + + // Only show URL for action types that actually use endpoints + const shouldShowUrl = actionType === 'HTTP Request' || actionType === 'Database Query'; + const endpoint = nodeData.config?.endpoint as string | undefined; + const hasContent = shouldShowUrl && endpoint; + return ( - +
- {nodeData.label} + {displayTitle}
- {nodeData.description && {nodeData.description}} + {displayDescription && {displayDescription}}
- -
-
- Action: {(nodeData.config?.actionType as string) || 'HTTP Request'} -
- {(nodeData.config?.endpoint as string | undefined) && ( -
- URL: {nodeData.config?.endpoint as string} -
- )} -
-
+ {hasContent && ( + +
URL: {endpoint}
+
+ )}
); }); diff --git a/components/workflow/nodes/condition-node.tsx b/components/workflow/nodes/condition-node.tsx index e1bbc94b..5e4179b4 100644 --- a/components/workflow/nodes/condition-node.tsx +++ b/components/workflow/nodes/condition-node.tsx @@ -15,23 +15,29 @@ import type { WorkflowNodeData } from '@/lib/workflow-store'; export const ConditionNode = memo(({ data, selected }: NodeProps) => { const nodeData = data as WorkflowNodeData; if (!nodeData) return null; + + const condition = (nodeData.config?.condition as string) || 'If true'; + const displayTitle = nodeData.label || condition; + const displayDescription = nodeData.description || 'Condition'; + const hasContent = !!condition; + return ( - +
- {nodeData.label} + {displayTitle}
- {nodeData.description && {nodeData.description}} + {displayDescription && {displayDescription}}
- -
- Condition: {(nodeData.config?.condition as string) || 'If true'} -
-
+ {hasContent && ( + +
{condition}
+
+ )}
); }); diff --git a/components/workflow/nodes/transform-node.tsx b/components/workflow/nodes/transform-node.tsx index c9bf88ce..671a465f 100644 --- a/components/workflow/nodes/transform-node.tsx +++ b/components/workflow/nodes/transform-node.tsx @@ -15,23 +15,29 @@ import type { WorkflowNodeData } from '@/lib/workflow-store'; export const TransformNode = memo(({ data, selected }: NodeProps) => { const nodeData = data as WorkflowNodeData; if (!nodeData) return null; + + const transformType = (nodeData.config?.transformType as string) || 'Map Data'; + const displayTitle = nodeData.label || transformType; + const displayDescription = nodeData.description || 'Transform'; + const hasContent = !!transformType; + return ( - +
- {nodeData.label} + {displayTitle}
- {nodeData.description && {nodeData.description}} + {displayDescription && {displayDescription}}
- -
- Transform: {(nodeData.config?.transformType as string) || 'Map Data'} -
-
+ {hasContent && ( + +
{transformType}
+
+ )}
); }); diff --git a/components/workflow/nodes/trigger-node.tsx b/components/workflow/nodes/trigger-node.tsx index db283a43..46bfafb9 100644 --- a/components/workflow/nodes/trigger-node.tsx +++ b/components/workflow/nodes/trigger-node.tsx @@ -15,23 +15,29 @@ import type { WorkflowNodeData } from '@/lib/workflow-store'; export const TriggerNode = memo(({ data, selected }: NodeProps) => { const nodeData = data as WorkflowNodeData; if (!nodeData) return null; + + const triggerType = (nodeData.config?.triggerType as string) || 'Manual'; + const displayTitle = nodeData.label || triggerType; + const displayDescription = nodeData.description || 'Trigger'; + const hasContent = !!triggerType; + return ( - +
- {nodeData.label} + {displayTitle}
- {nodeData.description && {nodeData.description}} + {displayDescription && {displayDescription}}
- -
- Trigger Type: {(nodeData.config?.triggerType as string) || 'Manual'} -
-
+ {hasContent && ( + +
{triggerType}
+
+ )}
); }); diff --git a/components/workflow/workflow-canvas.tsx b/components/workflow/workflow-canvas.tsx index f152576c..cdb3a734 100644 --- a/components/workflow/workflow-canvas.tsx +++ b/components/workflow/workflow-canvas.tsx @@ -38,29 +38,33 @@ import { Loader2, PlayCircle, Zap, GitBranch, Shuffle } from 'lucide-react'; const nodeTemplates = [ { type: 'trigger' as WorkflowNodeType, - label: 'Trigger', - description: 'Start your workflow', + label: '', + description: '', + displayLabel: 'Trigger', icon: PlayCircle, defaultConfig: { triggerType: 'Manual' }, }, { type: 'action' as WorkflowNodeType, - label: 'Action', - description: 'Perform an action', + label: '', + description: '', + displayLabel: 'Action', icon: Zap, - defaultConfig: { actionType: 'HTTP Request', endpoint: 'https://api.example.com' }, + defaultConfig: { actionType: 'HTTP Request' }, }, { type: 'condition' as WorkflowNodeType, - label: 'Condition', - description: 'Branch your workflow', + label: '', + description: '', + displayLabel: 'Condition', icon: GitBranch, defaultConfig: { condition: 'If true' }, }, { type: 'transform' as WorkflowNodeType, - label: 'Transform', - description: 'Transform data', + label: '', + description: '', + displayLabel: 'Transform', icon: Shuffle, defaultConfig: { transformType: 'Map Data' }, }, @@ -322,7 +326,7 @@ export function WorkflowCanvas() { className="focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none" > - {template.label} + {template.displayLabel}
{index < filteredArray.length - 1 &&
}
From 51975945ed67bbd2049daf55f62056b327c1fb3e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 22:08:39 -0600 Subject: [PATCH 008/340] fixes --- components/workflow/available-outputs.tsx | 42 ++++++++- lib/utils/template.ts | 106 ++++++++++++++++++---- 2 files changed, 129 insertions(+), 19 deletions(-) diff --git a/components/workflow/available-outputs.tsx b/components/workflow/available-outputs.tsx index f9f182aa..82a1ba62 100644 --- a/components/workflow/available-outputs.tsx +++ b/components/workflow/available-outputs.tsx @@ -11,6 +11,42 @@ interface AvailableOutputsProps { onInsertTemplate?: (template: string) => void; } +// Helper to get a display name for a node +const getNodeDisplayName = (node: { + id: string; + data: { + label?: string; + type: string; + config?: Record; + }; +}): string => { + // If user has set a custom label, use it + if (node.data.label) { + return node.data.label; + } + + // Otherwise, use type-specific defaults + if (node.data.type === 'action') { + const actionType = node.data.config?.actionType as string | undefined; + return actionType || 'HTTP Request'; + } + + if (node.data.type === 'trigger') { + const triggerType = node.data.config?.triggerType as string | undefined; + return triggerType || 'Manual'; + } + + if (node.data.type === 'condition') { + return 'Condition'; + } + + if (node.data.type === 'transform') { + return 'Transform'; + } + + return 'Node'; +}; + export function AvailableOutputs({ onInsertTemplate }: AvailableOutputsProps) { const [nodes] = useAtom(nodesAtom); const [edges] = useAtom(edgesAtom); @@ -135,7 +171,7 @@ export function AvailableOutputs({ onInsertTemplate }: AvailableOutputsProps) { ) : ( )} - {node.data.label} + {getNodeDisplayName(node)}
diff --git a/lib/utils/template.ts b/lib/utils/template.ts index fd98f191..c3147d41 100644 --- a/lib/utils/template.ts +++ b/lib/utils/template.ts @@ -13,10 +13,10 @@ export interface NodeOutputs { /** * Replace template variables in a string with actual values from node outputs * Supports: - * - Simple fields: {{nodeName.field}} - * - Nested fields: {{nodeName.nested.field}} - * - Array access: {{nodeName.items[0]}} - * - Entire node output: {{nodeName}} + * - Node ID references: {{$nodeId.field}} or {{$nodeId}} + * - Label references: {{nodeName.field}} or {{nodeName}} + * - Nested fields: {{$nodeId.nested.field}} + * - Array access: {{$nodeId.items[0]}} */ export function processTemplate(template: string, nodeOutputs: NodeOutputs): string { if (!template || typeof template !== 'string') { @@ -29,21 +29,44 @@ export function processTemplate(template: string, nodeOutputs: NodeOutputs): str return template.replace(pattern, (match, expression) => { const trimmed = expression.trim(); - // Handle special case: {{nodeName}} (entire output) - if (!trimmed.includes('.') && !trimmed.includes('[')) { - const nodeOutput = findNodeOutputByLabel(trimmed, nodeOutputs); - if (nodeOutput) { - return formatValue(nodeOutput.data); + // Check if this is a node ID reference (starts with $) + const isNodeIdRef = trimmed.startsWith('$'); + + if (isNodeIdRef) { + const withoutDollar = trimmed.substring(1); + + // Handle special case: {{$nodeId}} (entire output) + if (!withoutDollar.includes('.') && !withoutDollar.includes('[')) { + const nodeOutput = nodeOutputs[withoutDollar]; + if (nodeOutput) { + return formatValue(nodeOutput.data); + } + console.warn(`[Template] Node with ID "${withoutDollar}" not found in outputs`); + return match; } - console.warn(`[Template] Node "${trimmed}" not found in outputs`); - return match; // Keep original if not found - } - // Parse expression like "nodeName.field.nested" or "nodeName.items[0]" - const value = resolveExpression(trimmed, nodeOutputs); + // Parse expression like "$nodeId.field.nested" or "$nodeId.items[0]" + const value = resolveExpressionById(withoutDollar, nodeOutputs); + if (value !== undefined && value !== null) { + return formatValue(value); + } + } else { + // Legacy label-based references + // Handle special case: {{nodeName}} (entire output) + if (!trimmed.includes('.') && !trimmed.includes('[')) { + const nodeOutput = findNodeOutputByLabel(trimmed, nodeOutputs); + if (nodeOutput) { + return formatValue(nodeOutput.data); + } + console.warn(`[Template] Node "${trimmed}" not found in outputs`); + return match; + } - if (value !== undefined && value !== null) { - return formatValue(value); + // Parse expression like "nodeName.field.nested" or "nodeName.items[0]" + const value = resolveExpression(trimmed, nodeOutputs); + if (value !== undefined && value !== null) { + return formatValue(value); + } } // Log warning for debugging @@ -94,6 +117,57 @@ function findNodeOutputByLabel( return undefined; } +/** + * Resolve a dotted/bracketed expression using node ID like "nodeId.field.nested" or "nodeId.items[0]" + */ +function resolveExpressionById(expression: string, nodeOutputs: NodeOutputs): unknown { + // Split by dots, but handle array brackets + const parts = expression.split('.'); + + if (parts.length === 0) { + return undefined; + } + + // First part is the node ID + const nodeId = parts[0].trim(); + const nodeOutput = nodeOutputs[nodeId]; + + if (!nodeOutput) { + console.warn(`[Template] Node with ID "${nodeId}" not found in outputs`); + return undefined; + } + + // Start with the node's data + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = nodeOutput.data; + + console.log(`[Template] Resolving "${expression}". Node data:`, JSON.stringify(current, null, 2)); + + // Navigate through remaining parts + for (let i = 1; i < parts.length; i++) { + const part = parts[i].trim(); + + if (!part) { + continue; + } + + // Handle array access like "items[0]" + const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/); + if (arrayMatch) { + const [, field, index] = arrayMatch; + current = current?.[field]?.[parseInt(index, 10)]; + } else { + current = current?.[part]; + } + + if (current === undefined || current === null) { + return undefined; + } + } + + return current; +} + /** * Resolve a dotted/bracketed expression like "nodeName.field.nested" or "nodeName.items[0]" */ From 2cd218fb194d105e413cb6921623bd58a1a45e72 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 3 Nov 2025 22:17:17 -0600 Subject: [PATCH 009/340] generate text --- components/workflow/available-outputs.tsx | 5 +++ components/workflow/config/action-config.tsx | 47 ++++++++++++++++++++ components/workflow/nodes/action-node.tsx | 1 + lib/workflow-executor.server.ts | 42 +++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/components/workflow/available-outputs.tsx b/components/workflow/available-outputs.tsx index 82a1ba62..18530cc0 100644 --- a/components/workflow/available-outputs.tsx +++ b/components/workflow/available-outputs.tsx @@ -132,6 +132,11 @@ export function AvailableOutputs({ onInsertTemplate }: AvailableOutputsProps) { { field: 'data', description: 'Response data' }, { field: 'status', description: 'HTTP status code' }, ]; + } else if (actionType === 'Generate Text') { + return [ + { field: 'text', description: 'Generated text' }, + { field: 'model', description: 'Model used' }, + ]; } else if (node.data.type === 'trigger') { return [ { field: 'triggered', description: 'Trigger status' }, diff --git a/components/workflow/config/action-config.tsx b/components/workflow/config/action-config.tsx index 49fdce77..ebf46d5f 100644 --- a/components/workflow/config/action-config.tsx +++ b/components/workflow/config/action-config.tsx @@ -68,6 +68,13 @@ export function ActionConfig({ config, onUpdateConfig, disabled }: ActionConfigP Create Ticket Find Issues + + + + AI Gateway + + Generate Text +
@@ -381,6 +388,46 @@ export function ActionConfig({ config, onUpdateConfig, disabled }: ActionConfigP
)} + + {/* Generate Text fields */} + {config?.actionType === 'Generate Text' && ( + <> +
+ + +
+
+ +