Compare commits

..

1 Commits

Author SHA1 Message Date
Siddharth Ganesan
1d80456cb6 Hide 2026-01-24 12:43:47 -08:00
17 changed files with 201 additions and 302 deletions

View File

@@ -27,9 +27,8 @@ jobs:
steps:
- name: Extract version from commit message
id: extract
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
COMMIT_MSG="${{ github.event.head_commit.message }}"
# Only tag versions on main branch
if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
VERSION="${BASH_REMATCH[1]}"

View File

@@ -10,8 +10,8 @@ export { LandingLoopNode } from './landing-canvas/landing-block/landing-loop-nod
export { LandingNode } from './landing-canvas/landing-block/landing-node'
export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
export { LoopBlock } from './landing-canvas/landing-block/loop-block'
export type { SubBlockRowProps, TagProps } from './landing-canvas/landing-block/tag'
export { SubBlockRow, Tag } from './landing-canvas/landing-block/tag'
export type { TagProps } from './landing-canvas/landing-block/tag'
export { Tag } from './landing-canvas/landing-block/tag'
export type {
LandingBlockNode,
LandingCanvasProps,

View File

@@ -1,12 +1,12 @@
import React from 'react'
import { BookIcon } from 'lucide-react'
import {
SubBlockRow,
type SubBlockRowProps,
Tag,
type TagProps,
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/tag'
/**
* Data structure for a landing card component
* Matches the workflow block structure from the application
*/
export interface LandingCardData {
/** Icon element to display in the card header */
@@ -15,8 +15,8 @@ export interface LandingCardData {
color: string | '#f6f6f6'
/** Name/title of the card */
name: string
/** Optional subblock rows to display below the header */
tags?: SubBlockRowProps[]
/** Optional tags to display at the bottom of the card */
tags?: TagProps[]
}
/**
@@ -28,8 +28,7 @@ export interface LandingBlockProps extends LandingCardData {
}
/**
* Landing block component that displays a card with icon, name, and optional subblock rows
* Styled to match the application's workflow blocks
* Landing block component that displays a card with icon, name, and optional tags
* @param props - Component properties including icon, color, name, tags, and className
* @returns A styled block card component
*/
@@ -40,37 +39,33 @@ export const LandingBlock = React.memo(function LandingBlock({
tags,
className,
}: LandingBlockProps) {
const hasContentBelowHeader = tags && tags.length > 0
return (
<div
className={`z-10 flex w-[250px] flex-col rounded-[8px] border border-[#E5E5E5] bg-white ${className ?? ''}`}
className={`z-10 flex w-64 flex-col items-start gap-3 rounded-[14px] border border-[#E5E5E5] bg-[#FEFEFE] p-3 ${className ?? ''}`}
style={{
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
}}
>
{/* Header - matches workflow-block.tsx header styling */}
<div
className={`flex items-center justify-between p-[8px] ${hasContentBelowHeader ? 'border-[#E5E5E5] border-b' : ''}`}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div className='flex w-full items-center justify-between'>
<div className='flex items-center gap-2.5'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ background: color as string }}
className='flex h-6 w-6 items-center justify-center rounded-[8px] text-white'
style={{ backgroundColor: color as string }}
>
{icon}
</div>
<span className='truncate font-medium text-[#171717] text-[16px]' title={name}>
{name}
</span>
<p className='text-base text-card-foreground'>{name}</p>
</div>
<BookIcon className='h-4 w-4 text-muted-foreground' />
</div>
{/* Content - SubBlock Rows matching workflow-block.tsx */}
{hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{tags && tags.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{tags.map((tag) => (
<SubBlockRow key={tag.label} icon={tag.icon} label={tag.label} />
<Tag key={tag.label} icon={tag.icon} label={tag.label} />
))}
</div>
)}
) : null}
</div>
)
})

View File

@@ -7,14 +7,9 @@ import {
type LandingCardData,
} from '@/app/(landing)/components/hero/components/landing-canvas/landing-block/landing-block'
/**
* Handle Y offset from block top - matches HANDLE_POSITIONS.DEFAULT_Y_OFFSET
*/
const HANDLE_Y_OFFSET = 20
/**
* React Flow node component for the landing canvas
* Styled to match the application's workflow blocks
* Includes CSS animations and connection handles
* @param props - Component properties containing node data
* @returns A React Flow compatible node component
*/
@@ -46,15 +41,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
type='target'
position={Position.Left}
style={{
width: '7px',
height: '20px',
background: '#D1D1D1',
border: 'none',
borderRadius: '2px 0 0 2px',
top: `${HANDLE_Y_OFFSET}px`,
left: '-7px',
width: '12px',
height: '12px',
background: '#FEFEFE',
border: '1px solid #E5E5E5',
borderRadius: '50%',
top: '50%',
left: '-20px',
transform: 'translateY(-50%)',
zIndex: 10,
zIndex: 2,
}}
isConnectable={false}
/>
@@ -64,15 +59,15 @@ export const LandingNode = React.memo(function LandingNode({ data }: { data: Lan
type='source'
position={Position.Right}
style={{
width: '7px',
height: '20px',
background: '#D1D1D1',
border: 'none',
borderRadius: '0 2px 2px 0',
top: `${HANDLE_Y_OFFSET}px`,
right: '-7px',
width: '12px',
height: '12px',
background: '#FEFEFE',
border: '1px solid #E5E5E5',
borderRadius: '50%',
top: '50%',
right: '-20px',
transform: 'translateY(-50%)',
zIndex: 10,
zIndex: 2,
}}
isConnectable={false}
/>

View File

@@ -15,7 +15,6 @@ export interface LoopBlockProps {
/**
* Loop block container component that provides a styled container
* for grouping related elements with a dashed border
* Styled to match the application's subflow containers
* @param props - Component properties including children and styling
* @returns A styled loop container component
*/
@@ -30,33 +29,33 @@ export const LoopBlock = React.memo(function LoopBlock({
style={{
width: '1198px',
height: '528px',
borderRadius: '8px',
background: 'rgba(59, 130, 246, 0.08)',
borderRadius: '14px',
background: 'rgba(59, 130, 246, 0.10)',
position: 'relative',
...style,
}}
>
{/* Custom dashed border with SVG - 8px border radius to match blocks */}
{/* Custom dashed border with SVG */}
<svg
className='pointer-events-none absolute inset-0 h-full w-full'
style={{ borderRadius: '8px' }}
style={{ borderRadius: '14px' }}
preserveAspectRatio='none'
>
<path
className='landing-loop-animated-dash'
d='M 1190 527.5
L 8 527.5
A 7.5 7.5 0 0 1 0.5 520
L 0.5 8
A 7.5 7.5 0 0 1 8 0.5
L 1190 0.5
A 7.5 7.5 0 0 1 1197.5 8
L 1197.5 520
A 7.5 7.5 0 0 1 1190 527.5 Z'
d='M 1183.5 527.5
L 14 527.5
A 13.5 13.5 0 0 1 0.5 514
L 0.5 14
A 13.5 13.5 0 0 1 14 0.5
L 1183.5 0.5
A 13.5 13.5 0 0 1 1197 14
L 1197 514
A 13.5 13.5 0 0 1 1183.5 527.5 Z'
fill='none'
stroke='#3B82F6'
strokeWidth='1'
strokeDasharray='8 8'
strokeDasharray='12 12'
strokeLinecap='round'
/>
</svg>

View File

@@ -1,52 +1,25 @@
import React from 'react'
/**
* Properties for a subblock row component
* Matches the SubBlockRow pattern from workflow-block.tsx
* Properties for a tag component
*/
export interface SubBlockRowProps {
/** Icon element to display (optional, for visual context) */
icon?: React.ReactNode
/** Text label for the row title */
export interface TagProps {
/** Icon element to display in the tag */
icon: React.ReactNode
/** Text label for the tag */
label: string
/** Optional value to display on the right side */
value?: string
}
/**
* Kept for backwards compatibility
* Tag component for displaying labeled icons in a compact format
* @param props - Tag properties including icon and label
* @returns A styled tag component
*/
export type TagProps = SubBlockRowProps
/**
* SubBlockRow component matching the workflow block's subblock row style
* @param props - Row properties including label and optional value
* @returns A styled row component
*/
export const SubBlockRow = React.memo(function SubBlockRow({ label, value }: SubBlockRowProps) {
// Split label by colon to separate title and value if present
const [title, displayValue] = label.includes(':')
? label.split(':').map((s) => s.trim())
: [label, value]
export const Tag = React.memo(function Tag({ icon, label }: TagProps) {
return (
<div className='flex items-center gap-[8px]'>
<span className='min-w-0 truncate text-[#888888] text-[14px] capitalize' title={title}>
{title}
</span>
{displayValue && (
<span
className='flex-1 truncate text-right text-[#171717] text-[14px]'
title={displayValue}
>
{displayValue}
</span>
)}
<div className='flex w-fit items-center gap-1 rounded-[8px] border border-gray-300 bg-white px-2 py-0.5'>
<div className='h-3 w-3 text-muted-foreground'>{icon}</div>
<p className='text-muted-foreground text-xs leading-normal'>{label}</p>
</div>
)
})
/**
* Tag component - alias for SubBlockRow for backwards compatibility
*/
export const Tag = SubBlockRow

View File

@@ -9,10 +9,9 @@ import { LandingFlow } from '@/app/(landing)/components/hero/components/landing-
/**
* Visual constants for landing node dimensions
* Matches BLOCK_DIMENSIONS from the application
*/
export const CARD_WIDTH = 250
export const CARD_HEIGHT = 100
export const CARD_WIDTH = 256
export const CARD_HEIGHT = 92
/**
* Landing block node with positioning information

View File

@@ -4,29 +4,33 @@ import React from 'react'
import { type EdgeProps, getSmoothStepPath, Position } from 'reactflow'
/**
* Custom edge component with animated dashed line
* Styled to match the application's workflow edges with rectangular handles
* Custom edge component with animated dotted line that floats between handles
* @param props - React Flow edge properties
* @returns An animated dashed edge component
* @returns An animated dotted edge component
*/
export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style } = props
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style, data } =
props
// Adjust the connection points to connect flush with rectangular handles
// Handle width is 7px, positioned at -7px from edge
// Adjust the connection points to create floating effect
// Account for handle size (12px) and additional spacing
const handleRadius = 6 // Half of handle width (12px)
const floatingGap = 1 // Additional gap for floating effect
// Calculate adjusted positions based on edge direction
let adjustedSourceX = sourceX
let adjustedTargetX = targetX
if (sourcePosition === Position.Right) {
adjustedSourceX = sourceX + 1
adjustedSourceX = sourceX + handleRadius + floatingGap
} else if (sourcePosition === Position.Left) {
adjustedSourceX = sourceX - 1
adjustedSourceX = sourceX - handleRadius - floatingGap
}
if (targetPosition === Position.Left) {
adjustedTargetX = targetX - 1
adjustedTargetX = targetX - handleRadius - floatingGap
} else if (targetPosition === Position.Right) {
adjustedTargetX = targetX + 1
adjustedTargetX = targetX + handleRadius + floatingGap
}
const [path] = getSmoothStepPath({
@@ -36,8 +40,8 @@ export const LandingEdge = React.memo(function LandingEdge(props: EdgeProps) {
targetY,
sourcePosition,
targetPosition,
borderRadius: 8,
offset: 16,
borderRadius: 20,
offset: 10,
})
return (

View File

@@ -1,7 +1,16 @@
'use client'
import React from 'react'
import { ArrowUp, CodeIcon } from 'lucide-react'
import {
ArrowUp,
BinaryIcon,
BookIcon,
CalendarIcon,
CodeIcon,
Globe2Icon,
MessageSquareIcon,
VariableIcon,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { type Edge, type Node, Position } from 'reactflow'
import {
@@ -14,6 +23,7 @@ import {
JiraIcon,
LinearIcon,
NotionIcon,
OpenAIIcon,
OutlookIcon,
PackageSearchIcon,
PineconeIcon,
@@ -55,56 +65,67 @@ const SERVICE_TEMPLATES = {
/**
* Landing blocks for the canvas preview
* Styled to match the application's workflow blocks with subblock rows
*/
const LANDING_BLOCKS: LandingManualBlock[] = [
{
id: 'schedule',
name: 'Schedule',
color: '#7B68EE',
icon: <ScheduleIcon className='h-[16px] w-[16px] text-white' />,
icon: <ScheduleIcon className='h-4 w-4' />,
positions: {
mobile: { x: 8, y: 60 },
tablet: { x: 40, y: 120 },
desktop: { x: 60, y: 180 },
},
tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }],
tags: [
{ icon: <CalendarIcon className='h-3 w-3' />, label: '09:00AM Daily' },
{ icon: <Globe2Icon className='h-3 w-3' />, label: 'PST' },
],
},
{
id: 'knowledge',
name: 'Knowledge',
color: '#00B0B0',
icon: <PackageSearchIcon className='h-[16px] w-[16px] text-white' />,
icon: <PackageSearchIcon className='h-4 w-4' />,
positions: {
mobile: { x: 120, y: 140 },
tablet: { x: 220, y: 200 },
desktop: { x: 420, y: 241 },
},
tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }],
tags: [
{ icon: <BookIcon className='h-3 w-3' />, label: 'Product Vector DB' },
{ icon: <BinaryIcon className='h-3 w-3' />, label: 'Limit: 10' },
],
},
{
id: 'agent',
name: 'Agent',
color: '#802FFF',
icon: <AgentIcon className='h-[16px] w-[16px] text-white' />,
icon: <AgentIcon className='h-4 w-4' />,
positions: {
mobile: { x: 340, y: 60 },
tablet: { x: 540, y: 120 },
desktop: { x: 880, y: 142 },
},
tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }],
tags: [
{ icon: <OpenAIIcon className='h-3 w-3' />, label: 'gpt-5' },
{ icon: <MessageSquareIcon className='h-3 w-3' />, label: 'You are a support ag...' },
],
},
{
id: 'function',
name: 'Function',
color: '#FF402F',
icon: <CodeIcon className='h-[16px] w-[16px] text-white' />,
icon: <CodeIcon className='h-4 w-4' />,
positions: {
mobile: { x: 480, y: 220 },
tablet: { x: 740, y: 280 },
desktop: { x: 880, y: 340 },
},
tags: [{ label: 'Language: Python' }, { label: 'Code: time = "2025-09-01...' }],
tags: [
{ icon: <CodeIcon className='h-3 w-3' />, label: 'Python' },
{ icon: <VariableIcon className='h-3 w-3' />, label: 'time = "2025-09-01...' },
],
},
]

View File

@@ -229,7 +229,7 @@ function PricingCard({
*/
export default function LandingPricing() {
return (
<section id='pricing' className='px-4 pt-[23px] sm:px-0 sm:pt-[4px]' aria-label='Pricing plans'>
<section id='pricing' className='px-4 pt-[19px] sm:px-0 sm:pt-0' aria-label='Pricing plans'>
<h2 className='sr-only'>Pricing Plans</h2>
<div className='relative mx-auto w-full max-w-[1289px]'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-0 lg:grid-cols-4'>

View File

@@ -21,7 +21,7 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('26.1k')
const [githubStars, setGithubStars] = useState('25.8k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()

View File

@@ -1,15 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Textarea,
} from '@/components/emcn'
import { Badge, Button, Combobox, Input, Label, Textarea } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
@@ -46,14 +38,6 @@ const DEFAULT_ASSIGNMENT: Omit<VariableAssignment, 'id'> = {
isExisting: false,
}
/**
* Boolean value options for Combobox
*/
const BOOLEAN_OPTIONS: ComboboxOption[] = [
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]
/**
* Parses a value that might be a JSON string or already an array of VariableAssignment.
* This handles the case where workflows are imported with stringified values.
@@ -120,6 +104,8 @@ export function VariablesInput({
const allVariablesAssigned =
!hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0
// Initialize with one empty assignment if none exist and not in preview/disabled mode
// Also add assignment when first variable is created
useEffect(() => {
if (!isReadOnly && assignments.length === 0 && currentWorkflowVariables.length > 0) {
const initialAssignment: VariableAssignment = {
@@ -130,46 +116,45 @@ export function VariablesInput({
}
}, [currentWorkflowVariables.length, isReadOnly, assignments.length, setStoreValue])
// Clean up assignments when their associated variables are deleted
useEffect(() => {
if (isReadOnly || assignments.length === 0) return
const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id))
const validAssignments = assignments.filter((assignment) => {
// Keep assignments that haven't selected a variable yet
if (!assignment.variableId) return true
// Keep assignments whose variable still exists
return currentVariableIds.has(assignment.variableId)
})
// If all variables were deleted, clear all assignments
if (currentWorkflowVariables.length === 0) {
setStoreValue([])
} else if (validAssignments.length !== assignments.length) {
// Some assignments reference deleted variables, remove them
setStoreValue(validAssignments.length > 0 ? validAssignments : [])
}
}, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue])
const addAssignment = () => {
if (isReadOnly || allVariablesAssigned) return
if (isPreview || disabled || allVariablesAssigned) return
const newAssignment: VariableAssignment = {
...DEFAULT_ASSIGNMENT,
id: crypto.randomUUID(),
}
setStoreValue([...assignments, newAssignment])
setStoreValue([...(assignments || []), newAssignment])
}
const removeAssignment = (id: string) => {
if (isReadOnly) return
if (assignments.length === 1) {
setStoreValue([{ ...DEFAULT_ASSIGNMENT, id: crypto.randomUUID() }])
return
}
setStoreValue(assignments.filter((a) => a.id !== id))
if (isPreview || disabled) return
setStoreValue((assignments || []).filter((a) => a.id !== id))
}
const updateAssignment = (id: string, updates: Partial<VariableAssignment>) => {
if (isReadOnly) return
setStoreValue(assignments.map((a) => (a.id === id ? { ...a, ...updates } : a)))
if (isPreview || disabled) return
setStoreValue((assignments || []).map((a) => (a.id === id ? { ...a, ...updates } : a)))
}
const handleVariableSelect = (assignmentId: string, variableId: string) => {
@@ -184,12 +169,19 @@ export function VariablesInput({
}
}
const handleTagSelect = (newValue: string) => {
const handleTagSelect = (tag: string) => {
if (!activeFieldId) return
const assignment = assignments.find((a) => a.id === activeFieldId)
const originalValue = assignment?.value || ''
const textAfterCursor = originalValue.slice(cursorPosition)
if (!assignment) return
const currentValue = assignment.value || ''
const textBeforeCursor = currentValue.slice(0, cursorPosition)
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
const newValue =
currentValue.slice(0, lastOpenBracket) + tag + currentValue.slice(cursorPosition)
updateAssignment(activeFieldId, { value: newValue })
setShowTags(false)
@@ -198,7 +190,7 @@ export function VariablesInput({
const inputEl = valueInputRefs.current[activeFieldId]
if (inputEl) {
inputEl.focus()
const newCursorPos = newValue.length - textAfterCursor.length
const newCursorPos = lastOpenBracket + tag.length
inputEl.setSelectionRange(newCursorPos, newCursorPos)
}
}, 10)
@@ -280,18 +272,6 @@ export function VariablesInput({
}))
}
const syncOverlayScroll = (assignmentId: string, scrollLeft: number) => {
const overlay = overlayRefs.current[assignmentId]
if (overlay) overlay.scrollLeft = scrollLeft
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setShowTags(false)
setActiveSourceBlockId(null)
}
}
if (isPreview && (!assignments || assignments.length === 0)) {
return (
<div className='flex flex-col items-center justify-center rounded-md border border-border/40 bg-muted/20 py-8 text-center'>
@@ -322,7 +302,7 @@ export function VariablesInput({
return (
<div className='space-y-[8px]'>
{assignments.length > 0 && (
{assignments && assignments.length > 0 && (
<div className='space-y-[8px]'>
{assignments.map((assignment, index) => {
const collapsed = collapsedAssignments[assignment.id] || false
@@ -354,7 +334,7 @@ export function VariablesInput({
<Button
variant='ghost'
onClick={addAssignment}
disabled={isReadOnly || allVariablesAssigned}
disabled={isPreview || disabled || allVariablesAssigned}
className='h-auto p-0'
>
<Plus className='h-[14px] w-[14px]' />
@@ -363,7 +343,7 @@ export function VariablesInput({
<Button
variant='ghost'
onClick={() => removeAssignment(assignment.id)}
disabled={isReadOnly}
disabled={isPreview || disabled || assignments.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -378,26 +358,16 @@ export function VariablesInput({
<Label className='text-[13px]'>Variable</Label>
<Combobox
options={availableVars.map((v) => ({ label: v.name, value: v.id }))}
value={assignment.variableId || ''}
value={assignment.variableId || assignment.variableName || ''}
onChange={(value) => handleVariableSelect(assignment.id, value)}
placeholder='Select a variable...'
disabled={isReadOnly}
disabled={isPreview || disabled}
/>
</div>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
{assignment.type === 'boolean' ? (
<Combobox
options={BOOLEAN_OPTIONS}
value={assignment.value ?? ''}
onChange={(v) =>
!isReadOnly && updateAssignment(assignment.id, { value: v })
}
placeholder='Select value'
disabled={isReadOnly}
/>
) : assignment.type === 'object' || assignment.type === 'array' ? (
{assignment.type === 'object' || assignment.type === 'array' ? (
<div className='relative'>
<Textarea
ref={(el) => {
@@ -411,32 +381,26 @@ export function VariablesInput({
e.target.selectionStart ?? undefined
)
}
onKeyDown={handleKeyDown}
onFocus={() => {
if (!isReadOnly && !assignment.value?.trim()) {
if (!isPreview && !disabled && !assignment.value?.trim()) {
setActiveFieldId(assignment.id)
setCursorPosition(0)
setShowTags(true)
}
}}
onScroll={(e) => {
const overlay = overlayRefs.current[assignment.id]
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
placeholder={
assignment.type === 'object'
? '{\n "key": "value"\n}'
: '[\n 1, 2, 3\n]'
}
disabled={isReadOnly}
disabled={isPreview || disabled}
className={cn(
'min-h-[120px] font-mono text-sm text-transparent caret-foreground placeholder:text-muted-foreground/50',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
@@ -449,7 +413,10 @@ export function VariablesInput({
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-start overflow-auto bg-transparent px-3 py-2 font-mono text-sm'
style={{ scrollbarWidth: 'none' }}
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
}}
>
<div className='w-full whitespace-pre-wrap break-words'>
{formatDisplayText(assignment.value || '', {
@@ -474,34 +441,21 @@ export function VariablesInput({
e.target.selectionStart ?? undefined
)
}
onKeyDown={handleKeyDown}
onFocus={() => {
if (!isReadOnly && !assignment.value?.trim()) {
if (!isPreview && !disabled && !assignment.value?.trim()) {
setActiveFieldId(assignment.id)
setCursorPosition(0)
setShowTags(true)
}
}}
onScroll={(e) =>
syncOverlayScroll(assignment.id, e.currentTarget.scrollLeft)
}
onPaste={() =>
setTimeout(() => {
const input = valueInputRefs.current[assignment.id]
if (input)
syncOverlayScroll(
assignment.id,
(input as HTMLInputElement).scrollLeft
)
}, 0)
}
placeholder={`${assignment.type} value`}
disabled={isReadOnly}
disabled={isPreview || disabled}
autoComplete='off'
className={cn(
'allow-scroll w-full overflow-x-auto overflow-y-hidden text-transparent caret-foreground',
'allow-scroll w-full overflow-auto text-transparent caret-foreground',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2'
)}
style={{ overflowX: 'auto' }}
onDrop={(e) => handleDrop(e, assignment.id)}
onDragOver={(e) => handleDragOver(e, assignment.id)}
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
@@ -511,7 +465,7 @@ export function VariablesInput({
if (el) overlayRefs.current[assignment.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ scrollbarWidth: 'none' }}
style={{ overflowX: 'auto' }}
>
<div
className='w-full whitespace-pre'

View File

@@ -7,12 +7,12 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
type: 'spotify',
name: 'Spotify',
description: 'Search music, manage playlists, control playback, and access your library',
hideFromToolbar: true,
authMode: AuthMode.OAuth,
longDescription:
'Integrate Spotify into your workflow. Search for tracks, albums, artists, and playlists. Manage playlists, access your library, control playback, browse podcasts and audiobooks.',
docsLink: 'https://docs.sim.ai/tools/spotify',
category: 'tools',
hideFromToolbar: true,
bgColor: '#000000',
icon: SpotifyIcon,
subBlocks: [

View File

@@ -24,7 +24,7 @@ import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { filterNewEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
@@ -226,12 +226,9 @@ export function useCollaborativeWorkflow() {
case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
const { edges } = payload
if (Array.isArray(edges) && edges.length > 0) {
const blocks = useWorkflowStore.getState().blocks
const currentEdges = useWorkflowStore.getState().edges
const validEdges = filterValidEdges(edges, blocks)
const newEdges = filterNewEdges(validEdges, currentEdges)
const newEdges = filterNewEdges(edges, useWorkflowStore.getState().edges)
if (newEdges.length > 0) {
useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true })
useWorkflowStore.getState().batchAddEdges(newEdges)
}
}
break
@@ -1007,11 +1004,7 @@ export function useCollaborativeWorkflow() {
if (edges.length === 0) return false
// Filter out invalid edges (e.g., edges targeting trigger blocks) and duplicates
const blocks = useWorkflowStore.getState().blocks
const currentEdges = useWorkflowStore.getState().edges
const validEdges = filterValidEdges(edges, blocks)
const newEdges = filterNewEdges(validEdges, currentEdges)
const newEdges = filterNewEdges(edges, useWorkflowStore.getState().edges)
if (newEdges.length === 0) return false
const operationId = crypto.randomUUID()
@@ -1027,7 +1020,7 @@ export function useCollaborativeWorkflow() {
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchAddEdges(newEdges, { skipValidation: true })
useWorkflowStore.getState().batchAddEdges(newEdges)
if (!options?.skipUndoRedo) {
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
@@ -1491,23 +1484,9 @@ export function useCollaborativeWorkflow() {
if (blocks.length === 0) return false
// Filter out invalid edges (e.g., edges targeting trigger blocks)
// Combine existing blocks with new blocks for validation
const existingBlocks = useWorkflowStore.getState().blocks
const newBlocksMap = blocks.reduce(
(acc, block) => {
acc[block.id] = block
return acc
},
{} as Record<string, BlockState>
)
const allBlocks = { ...existingBlocks, ...newBlocksMap }
const validEdges = filterValidEdges(edges, allBlocks)
logger.info('Batch adding blocks collaboratively', {
blockCount: blocks.length,
edgeCount: validEdges.length,
filteredEdges: edges.length - validEdges.length,
edgeCount: edges.length,
})
const operationId = crypto.randomUUID()
@@ -1517,18 +1496,16 @@ export function useCollaborativeWorkflow() {
operation: {
operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { blocks, edges: validEdges, loops, parallels, subBlockValues },
payload: { blocks, edges, loops, parallels, subBlockValues },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchAddBlocks(blocks, validEdges, subBlockValues, {
skipEdgeValidation: true,
})
useWorkflowStore.getState().batchAddBlocks(blocks, edges, subBlockValues)
if (!options?.skipUndoRedo) {
undoRedo.recordBatchAddBlocks(blocks, validEdges, subBlockValues)
undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues)
}
return true

View File

@@ -2,9 +2,8 @@ import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type {
BlockState,
@@ -18,32 +17,6 @@ import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
/**
* Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger)
*/
function isValidEdge(
edge: Edge,
blocks: Record<string, { type: string; triggerMode?: boolean }>
): boolean {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) return false
if (isAnnotationOnlyBlock(sourceBlock.type)) return false
if (isAnnotationOnlyBlock(targetBlock.type)) return false
if (TriggerUtils.isTriggerBlock(targetBlock)) return false
return true
}
/**
* Filters edges to only include valid ones (target exists and is not a trigger block)
*/
export function filterValidEdges(
edges: Edge[],
blocks: Record<string, { type: string; triggerMode?: boolean }>
): Edge[] {
return edges.filter((edge) => isValidEdge(edge, blocks))
}
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false

View File

@@ -4,17 +4,13 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { isAnnotationOnlyBlock, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
filterNewEdges,
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
} from '@/stores/workflows/utils'
import { filterNewEdges, getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
import type {
Position,
SubBlockState,
@@ -95,6 +91,26 @@ function resolveInitialSubblockValue(config: SubBlockConfig): unknown {
return null
}
function isValidEdge(
edge: Edge,
blocks: Record<string, { type: string; triggerMode?: boolean }>
): boolean {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) return false
if (isAnnotationOnlyBlock(sourceBlock.type)) return false
if (isAnnotationOnlyBlock(targetBlock.type)) return false
if (TriggerUtils.isTriggerBlock(targetBlock)) return false
return true
}
function filterValidEdges(
edges: Edge[],
blocks: Record<string, { type: string; triggerMode?: boolean }>
): Edge[] {
return edges.filter((edge) => isValidEdge(edge, blocks))
}
const initialState = {
blocks: {},
edges: [],
@@ -340,8 +356,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
data?: Record<string, any>
}>,
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
subBlockValues?: Record<string, Record<string, unknown>>
) => {
const currentBlocks = get().blocks
const currentEdges = get().edges
@@ -366,10 +381,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
}
if (edges && edges.length > 0) {
// Skip validation if already validated by caller (e.g., collaborative layer)
const validEdges = options?.skipEdgeValidation
? edges
: filterValidEdges(edges, newBlocks)
const validEdges = filterValidEdges(edges, newBlocks)
const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
for (const edge of validEdges) {
if (!existingEdgeIds.has(edge.id)) {
@@ -504,12 +516,11 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved()
},
batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => {
batchAddEdges: (edges: Edge[]) => {
const blocks = get().blocks
const currentEdges = get().edges
// Skip validation if already validated by caller (e.g., collaborative layer)
const validEdges = options?.skipValidation ? edges : filterValidEdges(edges, blocks)
const validEdges = filterValidEdges(edges, blocks)
const filtered = filterNewEdges(validEdges, currentEdges)
const newEdges = [...currentEdges]

View File

@@ -203,13 +203,12 @@ export interface WorkflowActions {
batchAddBlocks: (
blocks: BlockState[],
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
subBlockValues?: Record<string, Record<string, unknown>>
) => void
batchRemoveBlocks: (ids: string[]) => void
batchToggleEnabled: (ids: string[]) => void
batchToggleHandles: (ids: string[]) => void
batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => void
batchAddEdges: (edges: Edge[]) => void
batchRemoveEdges: (ids: string[]) => void
clear: () => Partial<WorkflowState>
updateLastSaved: () => void