Compare commits

..

9 Commits

Author SHA1 Message Date
Vikhyath Mondreti
6c1d5586d5 fix(ci): commit message passed in via env var 2026-01-24 14:27:54 -08:00
Waleed
bcf6dc8828 fix(variables): boolean type support and input improvements (#2981)
* fix(variables): boolean type support and input improvements

* fix formatting
2026-01-24 13:52:09 -08:00
Vikhyath Mondreti
841cb638fb fix(edge-validation): race condition on collaborative add (#2980) 2026-01-24 13:19:52 -08:00
Emir Karabeg
c7db48e3a2 fix(landing): ui (#2979) 2026-01-24 13:04:06 -08:00
Siddharth Ganesan
4d844651c2 fix(integrations): hide from tool bar (#2544) 2026-01-24 12:45:14 -08:00
Siddharth Ganesan
9f916940b3 fix(copilot): fix edit summary for loops/parallels (#2978) 2026-01-24 12:36:43 -08:00
Siddharth Ganesan
3bbf7f5d1d fix(auth): copilot routes (#2977)
* Fix copilot auth

* Fix

* Fix

* Fix
2026-01-24 12:26:21 -08:00
Vikhyath Mondreti
68683258c3 fix(blog): slash actions description (#2976)
* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description
2026-01-24 11:46:07 -08:00
Vikhyath Mondreti
fc7f56e21b improvement(docs): loop and parallel var reference syntax (#2975) 2026-01-24 11:36:47 -08:00
19 changed files with 311 additions and 202 deletions

View File

@@ -27,8 +27,9 @@ jobs:
steps: steps:
- name: Extract version from commit message - name: Extract version from commit message
id: extract id: extract
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: | run: |
COMMIT_MSG="${{ github.event.head_commit.message }}"
# Only tag versions on main branch # Only tag versions on main branch
if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then
VERSION="${BASH_REMATCH[1]}" 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 { LandingNode } from './landing-canvas/landing-block/landing-node'
export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block' export type { LoopBlockProps } from './landing-canvas/landing-block/loop-block'
export { LoopBlock } from './landing-canvas/landing-block/loop-block' export { LoopBlock } from './landing-canvas/landing-block/loop-block'
export type { TagProps } from './landing-canvas/landing-block/tag' export type { SubBlockRowProps, TagProps } from './landing-canvas/landing-block/tag'
export { Tag } from './landing-canvas/landing-block/tag' export { SubBlockRow, Tag } from './landing-canvas/landing-block/tag'
export type { export type {
LandingBlockNode, LandingBlockNode,
LandingCanvasProps, LandingCanvasProps,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,7 @@
'use client' 'use client'
import React from 'react' import React from 'react'
import { import { ArrowUp, CodeIcon } from 'lucide-react'
ArrowUp,
BinaryIcon,
BookIcon,
CalendarIcon,
CodeIcon,
Globe2Icon,
MessageSquareIcon,
VariableIcon,
} from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { type Edge, type Node, Position } from 'reactflow' import { type Edge, type Node, Position } from 'reactflow'
import { import {
@@ -23,7 +14,6 @@ import {
JiraIcon, JiraIcon,
LinearIcon, LinearIcon,
NotionIcon, NotionIcon,
OpenAIIcon,
OutlookIcon, OutlookIcon,
PackageSearchIcon, PackageSearchIcon,
PineconeIcon, PineconeIcon,
@@ -65,67 +55,56 @@ const SERVICE_TEMPLATES = {
/** /**
* Landing blocks for the canvas preview * Landing blocks for the canvas preview
* Styled to match the application's workflow blocks with subblock rows
*/ */
const LANDING_BLOCKS: LandingManualBlock[] = [ const LANDING_BLOCKS: LandingManualBlock[] = [
{ {
id: 'schedule', id: 'schedule',
name: 'Schedule', name: 'Schedule',
color: '#7B68EE', color: '#7B68EE',
icon: <ScheduleIcon className='h-4 w-4' />, icon: <ScheduleIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 8, y: 60 }, mobile: { x: 8, y: 60 },
tablet: { x: 40, y: 120 }, tablet: { x: 40, y: 120 },
desktop: { x: 60, y: 180 }, desktop: { x: 60, y: 180 },
}, },
tags: [ tags: [{ label: 'Time: 09:00AM Daily' }, { label: 'Timezone: PST' }],
{ icon: <CalendarIcon className='h-3 w-3' />, label: '09:00AM Daily' },
{ icon: <Globe2Icon className='h-3 w-3' />, label: 'PST' },
],
}, },
{ {
id: 'knowledge', id: 'knowledge',
name: 'Knowledge', name: 'Knowledge',
color: '#00B0B0', color: '#00B0B0',
icon: <PackageSearchIcon className='h-4 w-4' />, icon: <PackageSearchIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 120, y: 140 }, mobile: { x: 120, y: 140 },
tablet: { x: 220, y: 200 }, tablet: { x: 220, y: 200 },
desktop: { x: 420, y: 241 }, desktop: { x: 420, y: 241 },
}, },
tags: [ tags: [{ label: 'Source: Product Vector DB' }, { label: 'Limit: 10' }],
{ icon: <BookIcon className='h-3 w-3' />, label: 'Product Vector DB' },
{ icon: <BinaryIcon className='h-3 w-3' />, label: 'Limit: 10' },
],
}, },
{ {
id: 'agent', id: 'agent',
name: 'Agent', name: 'Agent',
color: '#802FFF', color: '#802FFF',
icon: <AgentIcon className='h-4 w-4' />, icon: <AgentIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 340, y: 60 }, mobile: { x: 340, y: 60 },
tablet: { x: 540, y: 120 }, tablet: { x: 540, y: 120 },
desktop: { x: 880, y: 142 }, desktop: { x: 880, y: 142 },
}, },
tags: [ tags: [{ label: 'Model: gpt-5' }, { label: 'Prompt: You are a support ag...' }],
{ 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', id: 'function',
name: 'Function', name: 'Function',
color: '#FF402F', color: '#FF402F',
icon: <CodeIcon className='h-4 w-4' />, icon: <CodeIcon className='h-[16px] w-[16px] text-white' />,
positions: { positions: {
mobile: { x: 480, y: 220 }, mobile: { x: 480, y: 220 },
tablet: { x: 740, y: 280 }, tablet: { x: 740, y: 280 },
desktop: { x: 880, y: 340 }, desktop: { x: 880, y: 340 },
}, },
tags: [ tags: [{ label: 'Language: Python' }, { label: 'Code: time = "2025-09-01...' }],
{ 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() { export default function LandingPricing() {
return ( return (
<section id='pricing' className='px-4 pt-[19px] sm:px-0 sm:pt-0' aria-label='Pricing plans'> <section id='pricing' className='px-4 pt-[23px] sm:px-0 sm:pt-[4px]' aria-label='Pricing plans'>
<h2 className='sr-only'>Pricing Plans</h2> <h2 className='sr-only'>Pricing Plans</h2>
<div className='relative mx-auto w-full max-w-[1289px]'> <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'> <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 = {}) { export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.8k') const [githubStars, setGithubStars] = useState('26.1k')
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false) const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter() const router = useRouter()

View File

@@ -18,6 +18,8 @@ import {
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block' import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { CopilotToolCall } from '@/stores/panel' import type { CopilotToolCall } from '@/stores/panel'
@@ -1131,6 +1133,12 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
} }
const getBlockConfig = (blockType: string) => { const getBlockConfig = (blockType: string) => {
if (blockType === 'loop') {
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
}
if (blockType === 'parallel') {
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
}
return getBlock(blockType) return getBlock(blockType)
} }
@@ -1260,7 +1268,6 @@ async function handleRun(
const instance = getClientTool(toolCall.id) const instance = getClientTool(toolCall.id)
if (!instance && isIntegrationTool(toolCall.name)) { if (!instance && isIntegrationTool(toolCall.name)) {
setToolCallState(toolCall, 'executing')
onStateChange?.('executing') onStateChange?.('executing')
try { try {
await useCopilotStore.getState().executeIntegrationTool(toolCall.id) await useCopilotStore.getState().executeIntegrationTool(toolCall.id)

View File

@@ -496,7 +496,7 @@ export function DeployModal({
</div> </div>
)} )}
{apiDeployWarnings.length > 0 && ( {apiDeployWarnings.length > 0 && (
<div className='mb-3 rounded-[4px] border border-amber-500/30 bg-amber-500/10 p-3 text-amber-700 dark:text-amber-400 text-sm'> <div className='mb-3 rounded-[4px] border border-amber-500/30 bg-amber-500/10 p-3 text-amber-700 text-sm dark:text-amber-400'>
<div className='font-semibold'>Deployment Warning</div> <div className='font-semibold'>Deployment Warning</div>
{apiDeployWarnings.map((warning, index) => ( {apiDeployWarnings.map((warning, index) => (
<div key={index}>{warning}</div> <div key={index}>{warning}</div>

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants' import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { import type {
BlockState, BlockState,
@@ -17,6 +18,32 @@ import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] 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[] { export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => { return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false if (edge.source === edge.target) return false

View File

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

View File

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