Compare commits

..

5 Commits

Author SHA1 Message Date
Siddharth Ganesan
66d19c00db Stuff 2026-01-13 20:54:04 -08:00
Siddharth Ganesan
a45426bb6b Fix drag 2026-01-13 19:38:50 -08:00
Siddharth Ganesan
a3007d8980 Grouping 2026-01-13 19:08:28 -08:00
Siddharth Ganesan
8ec067d280 Ring light 2026-01-13 18:53:19 -08:00
Siddharth Ganesan
f04cd7c355 Groups v0 2026-01-13 18:23:50 -08:00
71 changed files with 2650 additions and 686 deletions

View File

@@ -552,53 +552,6 @@ All fields automatically have:
- `mode: 'trigger'` - Only shown in trigger mode
- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected
## Trigger Outputs & Webhook Input Formatting
### Important: Two Sources of Truth
There are two related but separate concerns:
1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`.
**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ:
- Tag dropdown shows fields that don't exist (broken variable resolution)
- Or actual data has fields not shown in dropdown (users can't discover them)
### When to Add a formatWebhookInput Handler
- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly.
- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler.
### Adding a Handler
In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
```typescript
if (foundWebhook.provider === '{service}') {
// Transform raw webhook body to match trigger outputs
return {
eventType: body.type,
resourceId: body.data?.id || '',
timestamp: body.created_at,
resource: body.data,
}
}
```
**Key rules:**
- Return fields that match your trigger `outputs` definition exactly
- No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }`
- No duplication (don't spread body AND add individual fields)
- Use `null` for missing optional data, not empty objects with empty strings
### Verify Alignment
Run the alignment checker:
```bash
bunx scripts/check-trigger-alignment.ts {service}
```
## Trigger Outputs
Trigger outputs use the same schema as block outputs (NOT tool outputs).
@@ -696,11 +649,6 @@ export const {service}WebhookTrigger: TriggerConfig = {
- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
- [ ] Added provider to `cleanupExternalWebhook` function
### Webhook Input Formatting
- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
- [ ] Handler returns fields matching trigger `outputs` exactly
- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment
### Testing
- [ ] Run `bun run type-check` to verify no TypeScript errors
- [ ] Restart dev server to pick up new triggers

View File

@@ -76,6 +76,14 @@
pointer-events: none;
}
/**
* Suppress the default selection ring for grouped selections
* These blocks show a more transparent ring via the component's ring overlay
*/
.react-flow__node.selected > div[data-grouped-selection="true"] > div::after {
box-shadow: none;
}
/**
* Color tokens - single source of truth for all colors
* Light mode: Warm theme

View File

@@ -29,6 +29,8 @@ export function BlockContextMenu({
onRemoveFromSubflow,
onOpenEditor,
onRename,
onGroupBlocks,
onUngroupBlocks,
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
@@ -47,6 +49,14 @@ export function BlockContextMenu({
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
// Check if we can group: need at least 2 blocks selected
const canGroup = selectedBlocks.length >= 2
// Check if we can ungroup: at least one selected block must be in a group
// Ungrouping will ungroup all blocks in that group (the entire group, not just selected blocks)
const hasGroupedBlock = selectedBlocks.some((b) => !!b.groupId)
const canUngroup = hasGroupedBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
if (allDisabled) return 'Enable'
@@ -141,6 +151,31 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Block group actions */}
{(canGroup || canUngroup) && <PopoverDivider />}
{canGroup && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onGroupBlocks()
onClose()
}}
>
Group Blocks
</PopoverItem>
)}
{canUngroup && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onUngroupBlocks()
onClose()
}}
>
Ungroup
</PopoverItem>
)}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
{isSingleBlock && !isSubflow && (

View File

@@ -24,6 +24,8 @@ export interface ContextMenuBlockInfo {
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
/** Group ID if block is in a group */
groupId?: string
}
/**
@@ -50,6 +52,8 @@ export interface BlockContextMenuProps {
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
onGroupBlocks: () => void
onUngroupBlocks: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { NodeProps } from 'reactflow'
import { type NodeProps, useReactFlow } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -10,6 +10,7 @@ import {
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ActionBar } from '../workflow-block/components'
import type { WorkflowBlockProps } from '../workflow-block/types'
@@ -198,6 +199,57 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
const userPermissions = useUserPermissionsContext()
// Get React Flow methods for group selection expansion
const { getNodes, setNodes } = useReactFlow()
const { getGroups } = useWorkflowStore()
/**
* Expands selection to include all group members on mouse down.
* This ensures that when a user starts dragging a note in a group,
* all other blocks in the group are also selected and will move together.
*/
const handleGroupMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only process left mouse button clicks
if (e.button !== 0) return
const groupId = data.groupId
if (!groupId) return
const groups = getGroups()
const group = groups[groupId]
if (!group || group.blockIds.length <= 1) return
const groupBlockIds = new Set(group.blockIds)
const allNodes = getNodes()
// Check if all group members are already selected
const allSelected = [...groupBlockIds].every((blockId) =>
allNodes.find((n) => n.id === blockId && n.selected)
)
if (allSelected) return
// Expand selection to include all group members
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isThisBlock = n.id === id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly clicked block
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
},
}
})
)
},
[id, data.groupId, getNodes, setNodes, getGroups]
)
/**
* Calculate deterministic dimensions based on content structure.
* Uses fixed width and computed height to avoid ResizeObserver jitter.
@@ -216,8 +268,14 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
dependencies: [isEmpty],
})
const isGroupedSelection = data.isGroupedSelection ?? false
return (
<div className='group relative'>
<div
className='group relative'
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
onMouseDown={handleGroupMouseDown}
>
<div
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useRef } from 'react'
import { memo, useCallback, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Button, Trash } from '@/components/emcn'
@@ -8,6 +8,7 @@ import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Global styles for subflow nodes (loop and parallel containers).
@@ -51,6 +52,8 @@ export interface SubflowNodeData {
isPreviewSelected?: boolean
kind: 'loop' | 'parallel'
name?: string
/** The ID of the group this subflow belongs to */
groupId?: string
}
/**
@@ -62,8 +65,9 @@ export interface SubflowNodeData {
* @returns Rendered subflow node component
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { getNodes, setNodes } = useReactFlow()
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
const { getGroups } = useWorkflowStore()
const blockRef = useRef<HTMLDivElement>(null)
const currentWorkflow = useCurrentWorkflow()
@@ -140,10 +144,57 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
diffStatus === 'edited' && 'ring-[var(--warning)]'
)
/**
* Expands selection to include all group members on mouse down.
* This ensures that when a user starts dragging a subflow in a group,
* all other blocks in the group are also selected and will move together.
*/
const handleGroupMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only process left mouse button clicks
if (e.button !== 0) return
const groupId = data.groupId
if (!groupId) return
const groups = getGroups()
const group = groups[groupId]
if (!group || group.blockIds.length <= 1) return
const groupBlockIds = new Set(group.blockIds)
const allNodes = getNodes()
// Check if all group members are already selected
const allSelected = [...groupBlockIds].every((blockId) =>
allNodes.find((n) => n.id === blockId && n.selected)
)
if (allSelected) return
// Expand selection to include all group members
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isThisBlock = n.id === id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly clicked block
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
},
}
})
)
},
[id, data.groupId, getNodes, setNodes, getGroups]
)
return (
<>
<SubflowNodeStyles />
<div className='group relative'>
<div className='group relative' onMouseDown={handleGroupMouseDown}>
<div
ref={blockRef}
onClick={() => setCurrentBlockId(id)}

View File

@@ -107,7 +107,7 @@ export const ActionBar = memo(
return (
<div
className={cn(
'-top-[46px] absolute right-0',
'-top-[46px] absolute right-0 z-[100]',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'

View File

@@ -12,6 +12,10 @@ export interface WorkflowBlockProps {
isPreview?: boolean
/** Whether this block is selected in preview mode */
isPreviewSelected?: boolean
/** Whether this block is selected as part of a group (not directly clicked) */
isGroupedSelection?: boolean
/** The ID of the group this block belongs to */
groupId?: string
subBlockValues?: Record<string, any>
blockState?: any
}

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Handle, type NodeProps, Position, useReactFlow, useUpdateNodeInternals } from 'reactflow'
import { Badge, Tooltip } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
@@ -915,8 +915,65 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const userPermissions = useUserPermissionsContext()
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
const isGroupedSelection = data.isGroupedSelection ?? false
// Get React Flow methods for group selection expansion
const { getNodes, setNodes } = useReactFlow()
const { getGroups } = useWorkflowStore()
/**
* Expands selection to include all group members on mouse down.
* This ensures that when a user starts dragging a block in a group,
* all other blocks in the group are also selected and will move together.
*/
const handleGroupMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only process left mouse button clicks
if (e.button !== 0) return
const groupId = data.groupId
if (!groupId) return
const groups = getGroups()
const group = groups[groupId]
if (!group || group.blockIds.length <= 1) return
const groupBlockIds = new Set(group.blockIds)
const allNodes = getNodes()
// Check if all group members are already selected
const allSelected = [...groupBlockIds].every((blockId) =>
allNodes.find((n) => n.id === blockId && n.selected)
)
if (allSelected) return
// Expand selection to include all group members
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isThisBlock = n.id === id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly clicked block
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
},
}
})
)
},
[id, data.groupId, getNodes, setNodes, getGroups]
)
return (
<div className='group relative'>
<div
className='group relative'
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
onMouseDown={handleGroupMouseDown}
>
<div
ref={contentRef}
onClick={handleClick}

View File

@@ -30,6 +30,7 @@ interface UseBlockVisualProps {
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
const isGroupedSelection = data.isGroupedSelection ?? false
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -64,8 +65,18 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
isGroupedSelection: !isPreview && isGroupedSelection,
}),
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
[
isActive,
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreview,
isPreviewSelected,
isGroupedSelection,
]
)
return {

View File

@@ -35,6 +35,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
const groupId = block?.data?.groupId
return {
id: n.id,
type: block?.type || '',
@@ -42,6 +43,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
groupId,
}
}),
[blocks]
@@ -49,14 +51,22 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
/**
* Handle right-click on a node (block)
* If the node is part of a multiselection, include all selected nodes.
* If the node is not selected, just use that node.
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
event.stopPropagation()
const selectedNodes = getNodes().filter((n) => n.selected)
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
// Get all currently selected nodes
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
// If the right-clicked node is already selected, use all selected nodes
// Otherwise, just use the right-clicked node
const isNodeSelected = selectedNodes.some((n) => n.id === node.id)
const nodesToUse = isNodeSelected && selectedNodes.length > 0 ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))

View File

@@ -11,6 +11,7 @@ export interface BlockRingOptions {
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
isGroupedSelection?: boolean
}
/**
@@ -21,8 +22,15 @@ export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
options
const {
isActive,
isPending,
isDeletedBlock,
diffStatus,
runPathStatus,
isPreviewSelection,
isGroupedSelection,
} = options
const hasRing =
isActive ||
@@ -30,17 +38,24 @@ export function getBlockRingStyles(options: BlockRingOptions): {
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus
!!runPathStatus ||
!!isGroupedSelection
const ringClassName = cn(
// Grouped selection: more transparent ring for blocks selected as part of a group
// Using rgba with the brand-secondary color (#33b4ff) at 40% opacity
isGroupedSelection &&
!isActive &&
'ring-[2px] ring-[rgba(51,180,255,0.4)]',
// Preview selection: static blue ring (standard thickness, no animation)
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
// Executing block: pulsing success ring with prominent thickness
isActive &&
!isPreviewSelection &&
!isGroupedSelection &&
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Non-active states use standard ring utilities
!isActive && hasRing && 'ring-[1.75px]',
// Non-active states use standard ring utilities (except grouped selection which has its own)
!isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Deleted state (highest priority after active/pending)

View File

@@ -264,12 +264,14 @@ const WorkflowContent = React.memo(() => {
const canUndo = undoRedoStack.undo.length > 0
const canRedo = undoRedoStack.redo.length > 0
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
setDragStartPosition: state.setDragStartPosition,
getDragStartPosition: state.getDragStartPosition,
}))
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
useWorkflowStore(
useShallow((state) => ({
updateNodeDimensions: state.updateNodeDimensions,
setDragStartPosition: state.setDragStartPosition,
getDragStartPosition: state.getDragStartPosition,
getGroups: state.getGroups,
}))
)
const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -356,14 +358,19 @@ const WorkflowContent = React.memo(() => {
/** Stores source node/handle info when a connection drag starts for drop-on-block detection. */
const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null)
/** Tracks whether onConnect successfully handled the connection (ReactFlow pattern). */
const connectionCompletedRef = useRef(false)
/** Stores start positions for multi-node drag undo/redo recording. */
const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>(
new Map()
)
/**
* Stores original positions and parentIds for nodes temporarily parented during group drag.
* Key: node ID, Value: { originalPosition, originalParentId }
*/
const groupDragTempParentsRef = useRef<
Map<string, { originalPosition: { x: number; y: number }; originalParentId?: string }>
>(new Map())
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
const pendingSelectionRef = useRef<Set<string> | null>(null)
@@ -461,6 +468,8 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -785,6 +794,35 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextGroupBlocks = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
if (blockIds.length >= 2) {
// Validate that all blocks share the same parent (or all have no parent)
// Blocks inside a subflow cannot be grouped with blocks outside that subflow
const parentIds = contextMenuBlocks.map((block) => block.parentId || null)
const uniqueParentIds = new Set(parentIds)
if (uniqueParentIds.size > 1) {
addNotification({
level: 'error',
message: 'Cannot group blocks from different subflows',
})
return
}
collaborativeGroupBlocks(blockIds)
}
}, [contextMenuBlocks, collaborativeGroupBlocks, addNotification])
const handleContextUngroupBlocks = useCallback(() => {
// Find the first block with a groupId
const groupedBlock = contextMenuBlocks.find((block) => block.groupId)
if (!groupedBlock?.groupId) return
// The block's groupId is the group we want to ungroup
// This is the direct group the block belongs to, which is the "top level" from the user's perspective
// (the most recently created group that contains this block)
collaborativeUngroupBlocks(groupedBlock.groupId)
}, [contextMenuBlocks, collaborativeUngroupBlocks])
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1909,6 +1947,7 @@ const WorkflowContent = React.memo(() => {
name: block.name,
isActive,
isPending,
groupId: block.data?.groupId,
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
@@ -2063,16 +2102,56 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
/** Handles node changes - applies changes and resolves parent-child selection conflicts.
* Also expands selection to include all group members when a grouped block is selected.
*/
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
let updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
if (hasSelectionChange) {
// Expand selection to include all group members
const groups = getGroups()
const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id))
const groupsToInclude = new Set<string>()
// Find all groups that have at least one selected member
selectedNodeIds.forEach((nodeId) => {
const groupId = blocks[nodeId]?.data?.groupId
if (groupId && groups[groupId]) {
groupsToInclude.add(groupId)
}
})
// Add all blocks from those groups to the selection
if (groupsToInclude.size > 0) {
const expandedNodeIds = new Set(selectedNodeIds)
groupsToInclude.forEach((groupId) => {
const group = groups[groupId]
if (group) {
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
}
})
// Update nodes to include expanded selection
if (expandedNodeIds.size > selectedNodeIds.size) {
updated = updated.map((n) => ({
...n,
selected: expandedNodeIds.has(n.id) ? true : n.selected,
}))
}
}
// Resolve parent-child conflicts
updated = resolveParentChildSelectionConflicts(updated, blocks)
}
return updated
})
},
[blocks]
[blocks, getGroups]
)
/**
@@ -2217,8 +2296,7 @@ const WorkflowContent = React.memo(() => {
)
/**
* Captures the source handle when a connection drag starts.
* Resets connectionCompletedRef to track if onConnect handles this connection.
* Captures the source handle when a connection drag starts
*/
const onConnectStart = useCallback((_event: any, params: any) => {
const handleId: string | undefined = params?.handleId
@@ -2227,7 +2305,6 @@ const WorkflowContent = React.memo(() => {
nodeId: params?.nodeId,
handleId: params?.handleId,
}
connectionCompletedRef.current = false
}, [])
/** Handles new edge connections with container boundary validation. */
@@ -2288,7 +2365,6 @@ const WorkflowContent = React.memo(() => {
isInsideContainer: true,
},
})
connectionCompletedRef.current = true
return
}
@@ -2317,7 +2393,6 @@ const WorkflowContent = React.memo(() => {
}
: undefined,
})
connectionCompletedRef.current = true
}
},
[addEdge, getNodes, blocks]
@@ -2326,9 +2401,8 @@ const WorkflowContent = React.memo(() => {
/**
* Handles connection drag end. Detects if the edge was dropped over a block
* and automatically creates a connection to that block's target handle.
*
* Uses connectionCompletedRef to check if onConnect already handled this connection
* (ReactFlow pattern for distinguishing handle-to-handle vs handle-to-body drops).
* Only creates a connection if ReactFlow didn't already handle it (e.g., when
* dropping on the block body instead of a handle).
*/
const onConnectEnd = useCallback(
(event: MouseEvent | TouchEvent) => {
@@ -2340,12 +2414,6 @@ const WorkflowContent = React.memo(() => {
return
}
// If onConnect already handled this connection, skip (handle-to-handle case)
if (connectionCompletedRef.current) {
connectionSourceRef.current = null
return
}
// Get cursor position in flow coordinates
const clientPos = 'changedTouches' in event ? event.changedTouches[0] : event
const flowPosition = screenToFlowPosition({
@@ -2356,14 +2424,25 @@ const WorkflowContent = React.memo(() => {
// Find node under cursor
const targetNode = findNodeAtPosition(flowPosition)
// Create connection if valid target found (handle-to-body case)
// Create connection if valid target found AND edge doesn't already exist
// ReactFlow's onConnect fires first when dropping on a handle, so we check
// if that connection already exists to avoid creating duplicates.
// IMPORTANT: We must read directly from the store (not React state) because
// the store update from ReactFlow's onConnect may not have triggered a
// React re-render yet when this callback runs (typically 1-2ms later).
if (targetNode && targetNode.id !== source.nodeId) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
const currentEdges = useWorkflowStore.getState().edges
const edgeAlreadyExists = currentEdges.some(
(e) => e.source === source.nodeId && e.target === targetNode.id
)
if (!edgeAlreadyExists) {
onConnect({
source: source.nodeId,
sourceHandle: source.handleId,
target: targetNode.id,
targetHandle: 'target',
})
}
}
connectionSourceRef.current = null
@@ -2533,9 +2612,55 @@ const WorkflowContent = React.memo(() => {
parentId: currentParentId,
})
// Capture all selected nodes' positions for multi-node undo/redo
// Expand selection to include all group members before capturing positions
const groups = getGroups()
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
// Find the group of the dragged node
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
// If the dragged node is in a group, expand selection to include all group members
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
const group = groups[draggedBlockGroupId]
const groupBlockIds = new Set(group.blockIds)
// Check if we need to expand selection
const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
if (needsExpansion) {
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isDirectlyDragged = n.id === node.id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly dragged node
isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
},
}
})
)
}
}
// Capture all selected nodes' positions for multi-node undo/redo
// Re-get nodes after potential selection expansion
const updatedNodes = getNodes()
const selectedNodes = updatedNodes.filter((n) => {
// Always include the dragged node
if (n.id === node.id) return true
// Include node if it's selected OR if it's in the same group as the dragged node
if (n.selected) return true
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
return groups[draggedBlockGroupId].blockIds.includes(n.id)
}
return false
})
multiNodeDragStartRef.current.clear()
selectedNodes.forEach((n) => {
const block = blocks[n.id]
@@ -2547,8 +2672,63 @@ const WorkflowContent = React.memo(() => {
})
}
})
// Set up temporary parent-child relationships for group members
// This leverages React Flow's built-in parent-child drag behavior
// BUT: Only do this if NOT all group members are already selected
// If all are selected, React Flow's native multiselect drag will handle it
groupDragTempParentsRef.current.clear()
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
const group = groups[draggedBlockGroupId]
if (group.blockIds.length > 1) {
// Check if all group members are already selected
const allGroupMembersSelected = group.blockIds.every((blockId) =>
updatedNodes.find((n) => n.id === blockId && n.selected)
)
// Only use temporary parent approach if NOT all members are selected
// (i.e., when click-and-dragging on an unselected grouped block)
if (!allGroupMembersSelected) {
// Get the dragged node's absolute position for calculating relative positions
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
setNodes((nodes) =>
nodes.map((n) => {
// Skip the dragged node - it becomes the temporary parent
if (n.id === node.id) return n
// Only process nodes in the same group
if (group.blockIds.includes(n.id)) {
// Store original position and parentId for restoration later
groupDragTempParentsRef.current.set(n.id, {
originalPosition: { ...n.position },
originalParentId: n.parentId,
})
// Get this node's absolute position
const nodeAbsPos = getNodeAbsolutePosition(n.id)
// Calculate position relative to the dragged node
const relativePosition = {
x: nodeAbsPos.x - draggedNodeAbsPos.x,
y: nodeAbsPos.y - draggedNodeAbsPos.y,
}
return {
...n,
parentId: node.id, // Temporarily make this a child of the dragged node
position: relativePosition,
extent: undefined, // Remove extent constraint during drag
}
}
return n
})
)
}
}
}
},
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
[blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition]
)
/** Handles node drag stop to establish parent-child relationships. */
@@ -2556,13 +2736,93 @@ const WorkflowContent = React.memo(() => {
(_event: React.MouseEvent, node: any) => {
clearDragHighlights()
// Compute absolute positions for group members before restoring parentIds
// We need to do this first because getNodes() will return stale data after setNodes
const computedGroupPositions = new Map<string, { x: number; y: number }>()
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
if (groupDragTempParentsRef.current.size > 0) {
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
const currentNodes = getNodes()
// Compute absolute positions for all temporarily parented nodes
for (const [nodeId, _tempData] of groupDragTempParentsRef.current) {
const nodeData = currentNodes.find((n) => n.id === nodeId)
if (nodeData) {
// The node's current position is relative to the dragged node
computedGroupPositions.set(nodeId, {
x: draggedNodeAbsPos.x + nodeData.position.x,
y: draggedNodeAbsPos.y + nodeData.position.y,
})
}
}
// Also store the dragged node's absolute position
computedGroupPositions.set(node.id, draggedNodeAbsPos)
// Restore temporary parent-child relationships
setNodes((nodes) =>
nodes.map((n) => {
const tempData = groupDragTempParentsRef.current.get(n.id)
if (tempData) {
const absolutePosition = computedGroupPositions.get(n.id) || n.position
return {
...n,
parentId: tempData.originalParentId,
position: absolutePosition,
extent: tempData.originalParentId ? ('parent' as const) : undefined,
}
}
return n
})
)
groupDragTempParentsRef.current.clear()
}
// Get all selected nodes to update their positions too
const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
let selectedNodes = allNodes.filter((n) => n.selected)
// If multiple nodes are selected, update all their positions
// If the dragged node is in a group, include all group members
if (draggedBlockGroupId) {
const groups = getGroups()
const group = groups[draggedBlockGroupId]
if (group && group.blockIds.length > 1) {
const groupBlockIds = new Set(group.blockIds)
// Include the dragged node and all group members that aren't already selected
const groupNodes = allNodes.filter(
(n) => groupBlockIds.has(n.id) && !selectedNodes.some((sn) => sn.id === n.id)
)
selectedNodes = [...selectedNodes, ...groupNodes]
// Also ensure the dragged node is included
if (!selectedNodes.some((n) => n.id === node.id)) {
const draggedNode = allNodes.find((n) => n.id === node.id)
if (draggedNode) {
selectedNodes = [...selectedNodes, draggedNode]
}
}
}
}
// If multiple nodes are selected (or in a group), update all their positions
if (selectedNodes.length > 1) {
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
// Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
let positionUpdates: Array<{ id: string; position: { x: number; y: number } }>
if (computedGroupPositions.size > 0) {
// For group drags, use the pre-computed absolute positions
positionUpdates = selectedNodes.map((n) => {
const precomputedPos = computedGroupPositions.get(n.id)
if (precomputedPos) {
return { id: n.id, position: precomputedPos }
}
// For non-group members, use current position
return { id: n.id, position: n.position }
})
} else {
positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
}
collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current,
})
@@ -2846,6 +3106,7 @@ const WorkflowContent = React.memo(() => {
},
[
getNodes,
setNodes,
dragStartParentId,
potentialParentId,
updateNodeParent,
@@ -2864,6 +3125,7 @@ const WorkflowContent = React.memo(() => {
activeWorkflowId,
collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent,
getGroups,
]
)
@@ -3171,19 +3433,81 @@ const WorkflowContent = React.memo(() => {
/**
* Handles node click to select the node in ReactFlow.
* When clicking on a grouped block, also selects all other blocks in the group.
* Grouped blocks are marked with isGroupedSelection for different visual styling.
* Parent-child conflict resolution happens automatically in onNodesChange.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) =>
nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
}))
)
const groups = getGroups()
// Track which nodes are directly clicked vs. group-expanded
const directlySelectedIds = new Set<string>()
setNodes((nodes) => {
// First, calculate the base selection
let updatedNodes = nodes.map((n) => {
const isDirectlySelected = isMultiSelect
? n.id === node.id
? true
: n.selected
: n.id === node.id
if (isDirectlySelected) {
directlySelectedIds.add(n.id)
}
return {
...n,
selected: isDirectlySelected,
data: {
...n.data,
isGroupedSelection: false, // Reset grouped selection flag
},
}
})
// Expand selection to include all group members
const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
const groupsToInclude = new Set<string>()
// Find all groups that have at least one selected member
selectedNodeIds.forEach((nodeId) => {
const groupId = blocks[nodeId]?.data?.groupId
if (groupId && groups[groupId]) {
groupsToInclude.add(groupId)
}
})
// Add all blocks from those groups to the selection
if (groupsToInclude.size > 0) {
const expandedNodeIds = new Set(selectedNodeIds)
groupsToInclude.forEach((groupId) => {
const group = groups[groupId]
if (group) {
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
}
})
// Update nodes with expanded selection, marking group-expanded nodes
if (expandedNodeIds.size > selectedNodeIds.size) {
updatedNodes = updatedNodes.map((n) => {
const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
return {
...n,
selected: expandedNodeIds.has(n.id) ? true : n.selected,
data: {
...n.data,
isGroupedSelection: isGroupExpanded,
},
}
})
}
}
return updatedNodes
})
},
[setNodes]
[setNodes, blocks, getGroups]
)
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3418,6 +3742,8 @@ const WorkflowContent = React.memo(() => {
onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename}
onGroupBlocks={handleContextGroupBlocks}
onUngroupBlocks={handleContextUngroupBlocks}
hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')

View File

@@ -313,26 +313,6 @@ export const getBlock = (type: string): BlockConfig | undefined => {
return registry[normalized]
}
export const getLatestBlock = (baseType: string): BlockConfig | undefined => {
const normalized = baseType.replace(/-/g, '_')
const versionedKeys = Object.keys(registry).filter((key) => {
const match = key.match(new RegExp(`^${normalized}_v(\\d+)$`))
return match !== null
})
if (versionedKeys.length > 0) {
const sorted = versionedKeys.sort((a, b) => {
const versionA = Number.parseInt(a.match(/_v(\d+)$/)?.[1] || '0', 10)
const versionB = Number.parseInt(b.match(/_v(\d+)$/)?.[1] || '0', 10)
return versionB - versionA
})
return registry[sorted[0]]
}
return registry[normalized]
}
export const getBlockByToolName = (toolName: string): BlockConfig | undefined => {
return Object.values(registry).find((block) => block.tools?.access?.includes(toolName))
}

View File

@@ -378,10 +378,21 @@ function buildManualTriggerOutput(
}
function buildIntegrationTriggerOutput(
_finalInput: unknown,
finalInput: unknown,
workflowInput: unknown
): NormalizedBlockOutput {
return isPlainObject(workflowInput) ? (workflowInput as NormalizedBlockOutput) : {}
const base: NormalizedBlockOutput = isPlainObject(workflowInput)
? ({ ...(workflowInput as Record<string, unknown>) } as NormalizedBlockOutput)
: {}
if (isPlainObject(finalInput)) {
Object.assign(base, finalInput as Record<string, unknown>)
base.input = { ...(finalInput as Record<string, unknown>) }
} else {
base.input = finalInput
}
return mergeFilesIntoOutput(base, workflowInput)
}
function extractSubBlocks(block: SerializedBlock): Record<string, unknown> | undefined {

View File

@@ -22,7 +22,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, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
@@ -242,10 +242,7 @@ export function useCollaborativeWorkflow() {
case EDGES_OPERATIONS.BATCH_ADD_EDGES: {
const { edges } = payload
if (Array.isArray(edges) && edges.length > 0) {
const newEdges = filterNewEdges(edges, workflowStore.edges)
if (newEdges.length > 0) {
workflowStore.batchAddEdges(newEdges)
}
workflowStore.batchAddEdges(edges)
}
break
}
@@ -427,6 +424,35 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-update-parent from remote user')
break
}
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
const { blockIds, groupId } = payload
logger.info('Received group-blocks from remote user', {
userId,
groupId,
blockCount: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0 && groupId) {
workflowStore.groupBlocks(blockIds, groupId)
}
logger.info('Successfully applied group-blocks from remote user')
break
}
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
const { groupId } = payload
logger.info('Received ungroup-blocks from remote user', {
userId,
groupId,
})
if (groupId) {
workflowStore.ungroupBlocks(groupId)
}
logger.info('Successfully applied ungroup-blocks from remote user')
break
}
}
}
} catch (error) {
@@ -979,9 +1005,6 @@ export function useCollaborativeWorkflow() {
if (edges.length === 0) return false
const newEdges = filterNewEdges(edges, workflowStore.edges)
if (newEdges.length === 0) return false
const operationId = crypto.randomUUID()
addToQueue({
@@ -989,16 +1012,16 @@ export function useCollaborativeWorkflow() {
operation: {
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
target: OPERATION_TARGETS.EDGES,
payload: { edges: newEdges },
payload: { edges },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.batchAddEdges(newEdges)
workflowStore.batchAddEdges(edges)
if (!options?.skipUndoRedo) {
newEdges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
edges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
}
return true
@@ -1590,6 +1613,83 @@ export function useCollaborativeWorkflow() {
]
)
const collaborativeGroupBlocks = useCallback(
(blockIds: string[]) => {
if (!isInActiveRoom()) {
logger.debug('Skipping group blocks - not in active workflow')
return null
}
if (blockIds.length < 2) {
logger.debug('Cannot group fewer than 2 blocks')
return null
}
const groupId = crypto.randomUUID()
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, groupId },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.groupBlocks(blockIds, groupId)
undoRedo.recordGroupBlocks(blockIds, groupId)
logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length })
return groupId
},
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
const collaborativeUngroupBlocks = useCallback(
(groupId: string) => {
if (!isInActiveRoom()) {
logger.debug('Skipping ungroup blocks - not in active workflow')
return []
}
const groups = workflowStore.getGroups()
const group = groups[groupId]
if (!group) {
logger.warn('Cannot ungroup - group not found', { groupId })
return []
}
const blockIds = [...group.blockIds]
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.ungroupBlocks(groupId)
undoRedo.recordUngroupBlocks(groupId, blockIds)
logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
return blockIds
},
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
return {
// Connection status
isConnected,
@@ -1628,6 +1728,10 @@ export function useCollaborativeWorkflow() {
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
// Collaborative block group operations
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
// Direct access to stores for non-collaborative operations
workflowStore,
subBlockStore,

View File

@@ -22,7 +22,9 @@ import {
type BatchToggleHandlesOperation,
type BatchUpdateParentOperation,
createOperationEntry,
type GroupBlocksOperation,
runWithUndoRedoRecordingSuspended,
type UngroupBlocksOperation,
type UpdateParentOperation,
useUndoRedoStore,
} from '@/stores/undo-redo'
@@ -874,6 +876,46 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
// Undoing group = ungroup (inverse is ungroup operation)
const inverseOp = entry.inverse as unknown as UngroupBlocksOperation
const { groupId } = inverseOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds: inverseOp.data.blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.ungroupBlocks(groupId)
logger.debug('Undid group blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
// Undoing ungroup = re-group (inverse is group operation)
const inverseOp = entry.inverse as unknown as GroupBlocksOperation
const { groupId, blockIds } = inverseOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.groupBlocks(blockIds, groupId)
logger.debug('Undid ungroup blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data
@@ -1482,6 +1524,46 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
// Redo group = group again
const groupOp = entry.operation as GroupBlocksOperation
const { groupId, blockIds } = groupOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.groupBlocks(blockIds, groupId)
logger.debug('Redid group blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
// Redo ungroup = ungroup again
const ungroupOp = entry.operation as UngroupBlocksOperation
const { groupId } = ungroupOp.data
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds: ungroupOp.data.blockIds },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.ungroupBlocks(groupId)
logger.debug('Redid ungroup blocks', { groupId })
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any
@@ -1793,6 +1875,66 @@ export function useUndoRedo() {
[activeWorkflowId, userId, undoRedoStore]
)
const recordGroupBlocks = useCallback(
(blockIds: string[], groupId: string) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: GroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds },
}
const inverse: UngroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds },
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded group blocks', { groupId, blockCount: blockIds.length })
},
[activeWorkflowId, userId, undoRedoStore]
)
const recordUngroupBlocks = useCallback(
(groupId: string, blockIds: string[], parentGroupId?: string) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: UngroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds, parentGroupId },
}
const inverse: GroupBlocksOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { groupId, blockIds },
}
const entry = createOperationEntry(operation, inverse)
undoRedoStore.push(activeWorkflowId, userId, entry)
logger.debug('Recorded ungroup blocks', { groupId, blockCount: blockIds.length })
},
[activeWorkflowId, userId, undoRedoStore]
)
return {
recordBatchAddBlocks,
recordBatchRemoveBlocks,
@@ -1806,6 +1948,8 @@ export function useUndoRedo() {
recordApplyDiff,
recordAcceptDiff,
recordRejectDiff,
recordGroupBlocks,
recordUngroupBlocks,
undo,
redo,
getStackSizes,

View File

@@ -1,4 +1,4 @@
import { getLatestBlock } from '@/blocks/registry'
import { getBlock } from '@/blocks/registry'
import { getAllTriggers } from '@/triggers'
export interface TriggerOption {
@@ -49,13 +49,22 @@ export function getTriggerOptions(): TriggerOption[] {
continue
}
const block = getLatestBlock(provider)
const block = getBlock(provider)
providerMap.set(provider, {
value: provider,
label: block?.name || formatProviderName(provider),
color: block?.bgColor || '#6b7280',
})
if (block) {
providerMap.set(provider, {
value: provider,
label: block.name, // Use block's display name (e.g., "Slack", "GitHub")
color: block.bgColor || '#6b7280', // Use block's hex color, fallback to gray
})
} else {
const label = formatProviderName(provider)
providerMap.set(provider, {
value: provider,
label,
color: '#6b7280', // gray fallback
})
}
}
const integrationOptions = Array.from(providerMap.values()).sort((a, b) =>

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,61 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout')
/** Default block dimensions for layout calculations */
const DEFAULT_BLOCK_WIDTH = 250
const DEFAULT_BLOCK_HEIGHT = 100
/**
* Identifies groups from blocks and calculates their bounding boxes.
* Returns a map of groupId to group info including bounding box and member block IDs.
*/
function identifyGroups(blocks: Record<string, BlockState>): Map<
string,
{
blockIds: string[]
bounds: { minX: number; minY: number; maxX: number; maxY: number }
}
> {
const groups = new Map<
string,
{
blockIds: string[]
bounds: { minX: number; minY: number; maxX: number; maxY: number }
}
>()
// Group blocks by their groupId
for (const [blockId, block] of Object.entries(blocks)) {
const groupId = block.data?.groupId
if (!groupId) continue
if (!groups.has(groupId)) {
groups.set(groupId, {
blockIds: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
})
}
const group = groups.get(groupId)!
group.blockIds.push(blockId)
// Update bounding box
const blockWidth = block.data?.width ?? DEFAULT_BLOCK_WIDTH
const blockHeight = block.data?.height ?? block.height ?? DEFAULT_BLOCK_HEIGHT
group.bounds.minX = Math.min(group.bounds.minX, block.position.x)
group.bounds.minY = Math.min(group.bounds.minY, block.position.y)
group.bounds.maxX = Math.max(group.bounds.maxX, block.position.x + blockWidth)
group.bounds.maxY = Math.max(group.bounds.maxY, block.position.y + blockHeight)
}
return groups
}
/**
* Applies automatic layout to all blocks in a workflow.
* Positions blocks in layers based on their connections (edges).
* Groups are treated as single units and laid out together.
*/
export function applyAutoLayout(
blocks: Record<string, BlockState>,
@@ -36,6 +88,11 @@ export function applyAutoLayout(
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
// Identify groups and their bounding boxes
const groups = identifyGroups(blocksCopy)
logger.info('Identified block groups for layout', { groupCount: groups.size })
// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -49,19 +106,112 @@ export function applyAutoLayout(
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
const rootBlocks: Record<string, BlockState> = {}
for (const id of layoutRootIds) {
rootBlocks[id] = blocksCopy[id]
// For groups, we need to:
// 1. Create virtual blocks representing each group
// 2. Replace grouped blocks with their group's virtual block
// 3. Layout the virtual blocks + ungrouped blocks
// 4. Apply position deltas to grouped blocks
// Track which blocks are in groups at root level
const groupedRootBlockIds = new Set<string>()
const groupRepresentatives = new Map<string, string>() // groupId -> representative blockId
// Store ORIGINAL positions of all grouped blocks before any modifications
const originalBlockPositions = new Map<string, { x: number; y: number }>()
for (const [_groupId, group] of groups) {
for (const blockId of group.blockIds) {
if (blocksCopy[blockId]) {
originalBlockPositions.set(blockId, { ...blocksCopy[blockId].position })
}
}
}
const rootEdges = edges.filter(
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
)
for (const [groupId, group] of groups) {
// Find if any blocks in this group are at root level
const rootGroupBlocks = group.blockIds.filter((id) => layoutRootIds.includes(id))
if (rootGroupBlocks.length > 0) {
// Mark all blocks in this group as grouped
for (const blockId of rootGroupBlocks) {
groupedRootBlockIds.add(blockId)
}
// Use the first block as the group's representative for layout
const representativeId = rootGroupBlocks[0]
groupRepresentatives.set(groupId, representativeId)
// Update the representative block's dimensions to match the group's bounding box
const bounds = group.bounds
const groupWidth = bounds.maxX - bounds.minX
const groupHeight = bounds.maxY - bounds.minY
blocksCopy[representativeId] = {
...blocksCopy[representativeId],
data: {
...blocksCopy[representativeId].data,
width: groupWidth,
height: groupHeight,
},
// Position at the group's top-left corner
position: { x: bounds.minX, y: bounds.minY },
}
}
}
// Build the blocks to layout: ungrouped blocks + group representatives
const rootBlocks: Record<string, BlockState> = {}
for (const id of layoutRootIds) {
// Skip grouped blocks that aren't representatives
if (groupedRootBlockIds.has(id)) {
// Only include if this is a group representative
for (const [groupId, repId] of groupRepresentatives) {
if (repId === id) {
rootBlocks[id] = blocksCopy[id]
break
}
}
} else {
rootBlocks[id] = blocksCopy[id]
}
}
// Remap edges: edges involving grouped blocks should connect to the representative
const blockToGroup = new Map<string, string>() // blockId -> groupId
for (const [groupId, group] of groups) {
for (const blockId of group.blockIds) {
blockToGroup.set(blockId, groupId)
}
}
const layoutBlockIds = new Set(Object.keys(rootBlocks))
const rootEdges = edges
.map((edge) => {
let source = edge.source
let target = edge.target
// Remap source if it's in a group
const sourceGroupId = blockToGroup.get(source)
if (sourceGroupId && groupRepresentatives.has(sourceGroupId)) {
source = groupRepresentatives.get(sourceGroupId)!
}
// Remap target if it's in a group
const targetGroupId = blockToGroup.get(target)
if (targetGroupId && groupRepresentatives.has(targetGroupId)) {
target = groupRepresentatives.get(targetGroupId)!
}
return { ...edge, source, target }
})
.filter((edge) => layoutBlockIds.has(edge.source) && layoutBlockIds.has(edge.target))
// Calculate subflow depths before laying out root blocks
// This ensures blocks connected to subflow ends are positioned correctly
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
// Store old positions for groups to calculate deltas
const oldGroupPositions = new Map<string, { x: number; y: number }>()
for (const [groupId, repId] of groupRepresentatives) {
oldGroupPositions.set(groupId, { ...blocksCopy[repId].position })
}
if (Object.keys(rootBlocks).length > 0) {
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
isContainer: false,
@@ -69,15 +219,49 @@ export function applyAutoLayout(
subflowDepths,
})
// Apply positions to ungrouped blocks and group representatives
for (const node of nodes.values()) {
blocksCopy[node.id].position = node.position
}
// For each group, calculate the delta and apply to ALL blocks in the group
for (const [groupId, repId] of groupRepresentatives) {
const oldGroupTopLeft = oldGroupPositions.get(groupId)!
const newGroupTopLeft = blocksCopy[repId].position
const deltaX = newGroupTopLeft.x - oldGroupTopLeft.x
const deltaY = newGroupTopLeft.y - oldGroupTopLeft.y
const group = groups.get(groupId)!
// Apply delta to ALL blocks in the group using their ORIGINAL positions
for (const blockId of group.blockIds) {
if (layoutRootIds.includes(blockId)) {
const originalPos = originalBlockPositions.get(blockId)
if (originalPos) {
blocksCopy[blockId].position = {
x: originalPos.x + deltaX,
y: originalPos.y + deltaY,
}
}
}
}
// Restore the representative's original dimensions
const originalBlock = blocks[repId]
if (originalBlock) {
blocksCopy[repId].data = {
...blocksCopy[repId].data,
width: originalBlock.data?.width,
height: originalBlock.data?.height,
}
}
}
}
layoutContainers(blocksCopy, edges, options)
logger.info('Auto layout completed successfully', {
blockCount: Object.keys(blocksCopy).length,
groupCount: groups.size,
})
return {

View File

@@ -26,9 +26,53 @@ export interface TargetedLayoutOptions extends LayoutOptions {
horizontalSpacing?: number
}
/**
* Identifies block groups from the blocks' groupId data.
* Returns a map of groupId to array of block IDs in that group.
*/
function identifyBlockGroups(blocks: Record<string, BlockState>): Map<string, string[]> {
const groups = new Map<string, string[]>()
for (const [blockId, block] of Object.entries(blocks)) {
const groupId = block.data?.groupId
if (!groupId) continue
if (!groups.has(groupId)) {
groups.set(groupId, [])
}
groups.get(groupId)!.push(blockId)
}
return groups
}
/**
* Expands changed block IDs to include all blocks in the same group.
* If any block in a group changed, all blocks in that group should be treated as changed.
*/
function expandChangedToGroups(
changedBlockIds: string[],
blockGroups: Map<string, string[]>,
blocks: Record<string, BlockState>
): Set<string> {
const expandedSet = new Set(changedBlockIds)
for (const blockId of changedBlockIds) {
const groupId = blocks[blockId]?.data?.groupId
if (groupId && blockGroups.has(groupId)) {
for (const groupBlockId of blockGroups.get(groupId)!) {
expandedSet.add(groupBlockId)
}
}
}
return expandedSet
}
/**
* Applies targeted layout to only reposition changed blocks.
* Unchanged blocks act as anchors to preserve existing layout.
* Blocks in groups are moved together as a unit.
*/
export function applyTargetedLayout(
blocks: Record<string, BlockState>,
@@ -45,9 +89,14 @@ export function applyTargetedLayout(
return blocks
}
const changedSet = new Set(changedBlockIds)
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
// Identify block groups
const blockGroups = identifyBlockGroups(blocksCopy)
// Expand changed set to include all blocks in affected groups
const changedSet = expandChangedToGroups(changedBlockIds, blockGroups, blocksCopy)
// Pre-calculate container dimensions by laying out their children (bottom-up)
// This ensures accurate widths/heights before root-level layout
prepareContainerDimensions(
@@ -71,7 +120,8 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
subflowDepths
subflowDepths,
blockGroups
)
for (const [parentId, childIds] of groups.children.entries()) {
@@ -83,7 +133,8 @@ export function applyTargetedLayout(
changedSet,
verticalSpacing,
horizontalSpacing,
subflowDepths
subflowDepths,
blockGroups
)
}
@@ -92,6 +143,7 @@ export function applyTargetedLayout(
/**
* Layouts a group of blocks (either root level or within a container)
* Blocks in block groups are moved together as a unit.
*/
function layoutGroup(
parentId: string | null,
@@ -101,7 +153,8 @@ function layoutGroup(
changedSet: Set<string>,
verticalSpacing: number,
horizontalSpacing: number,
subflowDepths: Map<string, number>
subflowDepths: Map<string, number>,
blockGroups: Map<string, string[]>
): void {
if (childIds.length === 0) return
@@ -141,7 +194,7 @@ function layoutGroup(
return
}
// Store old positions for anchor calculation
// Store old positions for anchor calculation and group delta tracking
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
@@ -185,14 +238,47 @@ function layoutGroup(
}
}
// Track which groups have already had their deltas applied
const processedGroups = new Set<string>()
// Apply new positions only to blocks that need layout
for (const id of needsLayout) {
const block = blocks[id]
const newPos = layoutPositions.get(id)
if (!block || !newPos) continue
block.position = {
x: newPos.x + offsetX,
y: newPos.y + offsetY,
const groupId = block.data?.groupId
// If this block is in a group, move all blocks in the group together
if (groupId && blockGroups.has(groupId) && !processedGroups.has(groupId)) {
processedGroups.add(groupId)
// Calculate the delta for this block (the one that needs layout)
const oldPos = oldPositions.get(id)
if (oldPos) {
const deltaX = newPos.x + offsetX - oldPos.x
const deltaY = newPos.y + offsetY - oldPos.y
// Apply delta to ALL blocks in the group using their original positions
for (const groupBlockId of blockGroups.get(groupId)!) {
const groupBlock = blocks[groupBlockId]
if (groupBlock && layoutEligibleChildIds.includes(groupBlockId)) {
const groupOriginalPos = oldPositions.get(groupBlockId)
if (groupOriginalPos) {
groupBlock.position = {
x: groupOriginalPos.x + deltaX,
y: groupOriginalPos.y + deltaY,
}
}
}
}
}
} else if (!groupId) {
// Non-grouped block - apply position normally
block.position = {
x: newPos.x + offsetX,
y: newPos.y + offsetY,
}
}
}
}

View File

@@ -41,11 +41,18 @@ export function isContainerType(blockType: string): boolean {
}
/**
* Checks if a block should be excluded from autolayout
* Checks if a block should be excluded from autolayout.
* Note blocks are excluded unless they are part of a group.
*/
export function shouldSkipAutoLayout(block?: BlockState): boolean {
export function shouldSkipAutoLayout(block?: BlockState, isInGroup?: boolean): boolean {
if (!block) return true
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
// If the block type is normally excluded (e.g., note), but it's in a group, include it
if (AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)) {
// Check if block is in a group - if so, include it in layout
const blockIsInGroup = isInGroup ?? !!block.data?.groupId
return !blockIsInGroup
}
return false
}
/**

View File

@@ -2290,7 +2290,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
},
}),
@@ -2302,7 +2302,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
},
}),
@@ -2318,7 +2318,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
triggerPath: { value: '' },
},
}),
@@ -2330,7 +2330,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
triggerPath: { value: '/api/webhooks/abc123' },
},
}),
@@ -2346,7 +2346,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
triggerPath: { value: '' },
},
@@ -2359,7 +2359,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
@@ -2371,18 +2371,14 @@ describe('hasWorkflowChanged', () => {
})
it.concurrent(
'should detect change when actual config differs but runtime metadata also differs',
'should detect change when triggerConfig differs but runtime metadata also differs',
() => {
// Test that when a real config field changes along with runtime metadata,
// the change is still detected. Using 'model' as the config field since
// triggerConfig is now excluded from comparison (individual trigger fields
// are compared separately).
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: null },
},
}),
@@ -2394,7 +2390,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4o' },
triggerConfig: { value: { event: 'pull_request' } },
webhookId: { value: 'wh_123456' },
},
}),
@@ -2406,12 +2402,8 @@ describe('hasWorkflowChanged', () => {
)
it.concurrent(
'should not detect change when triggerConfig differs (individual fields compared separately)',
'should not detect change when runtime metadata is added to current state',
() => {
// triggerConfig is excluded from comparison because:
// 1. Individual trigger fields are stored as separate subblocks and compared individually
// 2. The client populates triggerConfig with default values from trigger definitions,
// which aren't present in the deployed state, causing false positive change detection
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -2428,36 +2420,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
triggerConfig: { value: { event: 'pull_request', extraField: true } },
},
}),
},
})
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
}
)
it.concurrent(
'should not detect change when runtime metadata is added to current state',
() => {
const deployedState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
},
}),
},
})
const currentState = createWorkflowState({
blocks: {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_123456' },
triggerPath: { value: '/api/webhooks/abc123' },
},
@@ -2477,7 +2440,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
webhookId: { value: 'wh_old123' },
triggerPath: { value: '/api/webhooks/old' },
},
@@ -2490,7 +2453,7 @@ describe('hasWorkflowChanged', () => {
block1: createBlock('block1', {
type: 'starter',
subBlocks: {
model: { value: 'gpt-4' },
triggerConfig: { value: { event: 'push' } },
},
}),
},

View File

@@ -1174,5 +1174,6 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
groups: structuredClone(state.groups || {}),
}
}

View File

@@ -16,6 +16,8 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent',
GROUP_BLOCKS: 'group-blocks',
UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -87,6 +89,8 @@ export const UNDO_REDO_OPERATIONS = {
APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff',
GROUP_BLOCKS: 'group-blocks',
UNGROUP_BLOCKS: 'ungroup-blocks',
} as const
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]

View File

@@ -810,6 +810,104 @@ async function handleBlocksOperationTx(
break
}
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
const { blockIds, groupId } = payload
if (!Array.isArray(blockIds) || blockIds.length === 0 || !groupId) {
logger.debug('Invalid payload for group blocks operation')
return
}
logger.info(`Grouping ${blockIds.length} blocks into group ${groupId} in workflow ${workflowId}`)
// Update blocks: set groupId and push to groupStack
for (const blockId of blockIds) {
const [currentBlock] = await tx
.select({ data: workflowBlocks.data })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!currentBlock) {
logger.warn(`Block ${blockId} not found for grouping`)
continue
}
const currentData = (currentBlock?.data || {}) as Record<string, any>
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
const updatedData = {
...currentData,
groupId,
groupStack: [...currentStack, groupId],
}
await tx
.update(workflowBlocks)
.set({
data: updatedData,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Grouped ${blockIds.length} blocks into group ${groupId}`)
break
}
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
const { groupId, blockIds } = payload
if (!groupId || !Array.isArray(blockIds)) {
logger.debug('Invalid payload for ungroup blocks operation')
return
}
logger.info(`Ungrouping ${blockIds.length} blocks from group ${groupId} in workflow ${workflowId}`)
// Update blocks: pop from groupStack and set groupId to the previous level
for (const blockId of blockIds) {
const [currentBlock] = await tx
.select({ data: workflowBlocks.data })
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!currentBlock) {
logger.warn(`Block ${blockId} not found for ungrouping`)
continue
}
const currentData = (currentBlock?.data || {}) as Record<string, any>
const currentStack = Array.isArray(currentData.groupStack) ? [...currentData.groupStack] : []
// Pop the current groupId from the stack
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
currentStack.pop()
}
// The new groupId is the top of the remaining stack, or undefined if empty
const newGroupId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
let updatedData: Record<string, any>
if (newGroupId) {
updatedData = { ...currentData, groupId: newGroupId, groupStack: currentStack }
} else {
// Remove groupId and groupStack if stack is empty
const { groupId: _removed, groupStack: _removedStack, ...restData } = currentData
updatedData = restData
}
await tx
.update(workflowBlocks)
.set({
data: updatedData,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Ungrouped ${blockIds.length} blocks from group ${groupId}`)
break
}
default:
throw new Error(`Unsupported blocks operation: ${operation}`)
}

View File

@@ -465,6 +465,70 @@ export function setupOperationsHandlers(
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.GROUP_BLOCKS
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.UNGROUP_BLOCKS
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
room.lastModified = Date.now()
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: crypto.randomUUID() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
await persistWorkflowOperation(workflowId, {
operation,

View File

@@ -30,6 +30,8 @@ const WRITE_OPERATIONS: string[] = [
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
BLOCKS_OPERATIONS.GROUP_BLOCKS,
BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,

View File

@@ -221,6 +221,30 @@ export const BatchUpdateParentSchema = z.object({
operationId: z.string().optional(),
})
export const GroupBlocksSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.GROUP_BLOCKS),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
blockIds: z.array(z.string()),
groupId: z.string(),
name: z.string().optional(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const UngroupBlocksSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.UNGROUP_BLOCKS),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
groupId: z.string(),
blockIds: z.array(z.string()),
parentGroupId: z.string().optional(),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const WorkflowOperationSchema = z.union([
BlockOperationSchema,
BatchPositionUpdateSchema,
@@ -229,6 +253,8 @@ export const WorkflowOperationSchema = z.union([
BatchToggleEnabledSchema,
BatchToggleHandlesSchema,
BatchUpdateParentSchema,
GroupBlocksSchema,
UngroupBlocksSchema,
EdgeOperationSchema,
BatchAddEdgesSchema,
BatchRemoveEdgesSchema,

View File

@@ -25,6 +25,7 @@ function captureWorkflowSnapshot(): WorkflowState {
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
groups: rawState.groups || {},
lastSaved: Date.now(),
}
}

View File

@@ -126,6 +126,23 @@ export interface RejectDiffOperation extends BaseOperation {
}
}
export interface GroupBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.GROUP_BLOCKS
data: {
groupId: string
blockIds: string[]
}
}
export interface UngroupBlocksOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS
data: {
groupId: string
blockIds: string[]
parentGroupId?: string
}
}
export type Operation =
| BatchAddBlocksOperation
| BatchRemoveBlocksOperation
@@ -139,6 +156,8 @@ export type Operation =
| ApplyDiffOperation
| AcceptDiffOperation
| RejectDiffOperation
| GroupBlocksOperation
| UngroupBlocksOperation
export interface OperationEntry {
id: string

View File

@@ -6,8 +6,10 @@ import type {
BatchRemoveBlocksOperation,
BatchRemoveEdgesOperation,
BatchUpdateParentOperation,
GroupBlocksOperation,
Operation,
OperationEntry,
UngroupBlocksOperation,
} from '@/stores/undo-redo/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
@@ -164,6 +166,30 @@ export function createInverseOperation(operation: Operation): Operation {
},
}
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
const op = operation as GroupBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
data: {
groupId: op.data.groupId,
blockIds: op.data.blockIds,
},
} as UngroupBlocksOperation
}
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
const op = operation as UngroupBlocksOperation
return {
...operation,
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
data: {
groupId: op.data.groupId,
blockIds: op.data.blockIds,
},
} as GroupBlocksOperation
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)

View File

@@ -16,6 +16,7 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState {
edges: structuredClone(state.edges || []),
loops: structuredClone(state.loops || {}),
parallels: structuredClone(state.parallels || {}),
groups: structuredClone(state.groups || {}),
}
}

View File

@@ -298,11 +298,26 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
let workflowState: any
if (workflowData?.state) {
const blocks = workflowData.state.blocks || {}
// Reconstruct groups from blocks' groupId data
const reconstructedGroups: Record<string, { id: string; blockIds: string[] }> = {}
Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
const groupId = block?.data?.groupId
if (groupId) {
if (!reconstructedGroups[groupId]) {
reconstructedGroups[groupId] = { id: groupId, blockIds: [] }
}
reconstructedGroups[groupId].blockIds.push(blockId)
}
})
workflowState = {
blocks: workflowData.state.blocks || {},
blocks,
edges: workflowData.state.edges || [],
loops: workflowData.state.loops || {},
parallels: workflowData.state.parallels || {},
groups: reconstructedGroups,
lastSaved: Date.now(),
deploymentStatuses: {},
}
@@ -312,6 +327,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
edges: [],
loops: {},
parallels: {},
groups: {},
deploymentStatuses: {},
lastSaved: Date.now(),
}

View File

@@ -1,19 +1,5 @@
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return edgesToAdd.filter((edge) => {
if (edge.source === edge.target) return false
return !currentEdges.some(
(e) =>
e.source === edge.source &&
e.sourceHandle === edge.sourceHandle &&
e.target === edge.target &&
e.targetHandle === edge.targetHandle
)
})
}
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'

View File

@@ -297,7 +297,7 @@ describe('workflow store', () => {
expectEdgeConnects(edges, 'block-1', 'block-2')
})
it('should not add duplicate connections', () => {
it('should not add duplicate edges', () => {
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
@@ -309,6 +309,17 @@ describe('workflow store', () => {
const state = useWorkflowStore.getState()
expectEdgeCount(state, 1)
})
it('should prevent self-referencing edges', () => {
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Self', { x: 0, y: 0 })
batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-1' }])
const state = useWorkflowStore.getState()
expectEdgeCount(state, 0)
})
})
describe('batchRemoveEdges', () => {

View File

@@ -9,12 +9,7 @@ import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
filterNewEdges,
getUniqueBlockName,
mergeSubblockState,
normalizeName,
} from '@/stores/workflows/utils'
import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
import type {
Position,
SubBlockState,
@@ -100,6 +95,7 @@ const initialState = {
edges: [],
loops: {},
parallels: {},
groups: {},
lastSaved: undefined,
deploymentStatuses: {},
needsRedeployment: false,
@@ -501,11 +497,25 @@ export const useWorkflowStore = create<WorkflowStore>()(
batchAddEdges: (edges: Edge[]) => {
const currentEdges = get().edges
const filtered = filterNewEdges(edges, currentEdges)
const newEdges = [...currentEdges]
const existingEdgeIds = new Set(currentEdges.map((e) => e.id))
// Track existing connections to prevent duplicates (same source->target)
const existingConnections = new Set(currentEdges.map((e) => `${e.source}->${e.target}`))
for (const edge of filtered) {
for (const edge of edges) {
// Skip if edge ID already exists
if (existingEdgeIds.has(edge.id)) continue
// Skip self-referencing edges
if (edge.source === edge.target) continue
// Skip if connection already exists (same source and target)
const connectionKey = `${edge.source}->${edge.target}`
if (existingConnections.has(connectionKey)) continue
// Skip if would create a cycle
if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue
newEdges.push({
id: edge.id || crypto.randomUUID(),
source: edge.source,
@@ -515,6 +525,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
type: edge.type || 'default',
data: edge.data || {},
})
existingEdgeIds.add(edge.id)
existingConnections.add(connectionKey)
}
const blocks = get().blocks
@@ -566,6 +578,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
groups: state.groups,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
@@ -586,6 +599,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
Object.keys(workflowState.parallels || {}).length > 0
? workflowState.parallels
: generateParallelBlocks(nextBlocks)
const nextGroups = workflowState.groups || state.groups
return {
...state,
@@ -593,6 +607,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: nextEdges,
loops: nextLoops,
parallels: nextParallels,
groups: nextGroups,
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment:
workflowState.needsRedeployment !== undefined
@@ -1322,6 +1337,126 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => {
return get().dragStartPosition || null
},
groupBlocks: (blockIds: string[], groupId?: string) => {
if (blockIds.length === 0) return ''
const newGroupId = groupId || crypto.randomUUID()
const currentGroups = get().groups || {}
const currentBlocks = get().blocks
// Create the new group with all selected block IDs
const updatedGroups = { ...currentGroups }
updatedGroups[newGroupId] = {
id: newGroupId,
blockIds: [...blockIds],
}
// Update blocks: set groupId and push to groupStack
const newBlocks = { ...currentBlocks }
for (const blockId of blockIds) {
if (newBlocks[blockId]) {
const currentData = newBlocks[blockId].data || {}
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
newBlocks[blockId] = {
...newBlocks[blockId],
data: {
...currentData,
groupId: newGroupId,
groupStack: [...currentStack, newGroupId],
},
}
}
}
set({
blocks: newBlocks,
groups: updatedGroups,
})
get().updateLastSaved()
logger.info('Created block group', {
groupId: newGroupId,
blockCount: blockIds.length,
})
return newGroupId
},
ungroupBlocks: (groupId: string) => {
const currentGroups = get().groups || {}
const currentBlocks = get().blocks
const group = currentGroups[groupId]
if (!group) {
logger.warn('Attempted to ungroup non-existent group', { groupId })
return []
}
const blockIds = [...group.blockIds]
// Remove the group from the groups record
const updatedGroups = { ...currentGroups }
delete updatedGroups[groupId]
// Update blocks: pop from groupStack and set groupId to the previous level
const newBlocks = { ...currentBlocks }
for (const blockId of blockIds) {
if (newBlocks[blockId]) {
const currentData = { ...newBlocks[blockId].data }
const currentStack = Array.isArray(currentData.groupStack)
? [...currentData.groupStack]
: []
// Pop the current groupId from the stack
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
currentStack.pop()
}
// The new groupId is the top of the remaining stack, or undefined if empty
const newGroupId =
currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
if (newGroupId) {
currentData.groupId = newGroupId
currentData.groupStack = currentStack
} else {
// Remove groupId and groupStack if stack is empty
delete currentData.groupId
delete currentData.groupStack
}
newBlocks[blockId] = {
...newBlocks[blockId],
data: currentData,
}
}
}
set({
blocks: newBlocks,
groups: updatedGroups,
})
get().updateLastSaved()
logger.info('Ungrouped blocks', {
groupId,
blockCount: blockIds.length,
})
return blockIds
},
getGroupBlockIds: (groupId: string) => {
const groups = get().groups || {}
const group = groups[groupId]
if (!group) return []
return [...group.blockIds]
},
getGroups: () => {
return get().groups || {}
},
}),
{ name: 'workflow-store' }
)

View File

@@ -63,6 +63,11 @@ export interface BlockData {
// Container node type (for ReactFlow node type determination)
type?: string
// Block group membership
groupId?: string
/** Stack of group IDs for hierarchical grouping (oldest to newest) */
groupStack?: string[]
}
export interface BlockLayoutState {
@@ -144,6 +149,20 @@ export interface Variable {
value: unknown
}
/**
* Represents a group of blocks on the canvas.
* Groups can be nested (a group can contain other groups via block membership).
* When a block is in a group, it stores the groupId in its data.
*/
export interface BlockGroup {
/** Unique identifier for the group */
id: string
/** Optional display name for the group */
name?: string
/** Block IDs that are direct members of this group */
blockIds: string[]
}
export interface DragStartPosition {
id: string
x: number
@@ -157,6 +176,8 @@ export interface WorkflowState {
lastSaved?: number
loops: Record<string, Loop>
parallels: Record<string, Parallel>
/** Block groups for organizing blocks on the canvas */
groups?: Record<string, BlockGroup>
lastUpdate?: number
metadata?: {
name?: string
@@ -243,6 +264,28 @@ export interface WorkflowActions {
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
// Block group operations
/**
* Groups the specified blocks together.
* If any blocks are already in a group, they are removed from their current group first.
* @returns The new group ID
*/
groupBlocks: (blockIds: string[], groupId?: string) => string
/**
* Ungroups a group, removing it and releasing its blocks.
* If the group has a parent group, blocks are moved to the parent group.
* @returns The block IDs that were in the group
*/
ungroupBlocks: (groupId: string) => string[]
/**
* Gets all block IDs in a group, including blocks in nested groups (recursive).
*/
getGroupBlockIds: (groupId: string, recursive?: boolean) => string[]
/**
* Gets all groups in the workflow.
*/
getGroups: () => Record<string, BlockGroup>
}
export type WorkflowStore = WorkflowState & WorkflowActions

View File

@@ -3,7 +3,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { AGENT, isCustomTool } from '@/executor/constants'
import { useCustomToolsStore } from '@/stores/custom-tools'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { extractErrorMessage } from '@/tools/error-extractors'
import { tools } from '@/tools/registry'
import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types'
@@ -163,22 +162,14 @@ export async function executeRequest(
const externalResponse = await fetch(url, { method, headers, body })
if (!externalResponse.ok) {
let errorData: any
let errorContent
try {
errorData = await externalResponse.json()
errorContent = await externalResponse.json()
} catch (_e) {
try {
errorData = await externalResponse.text()
} catch (_e2) {
errorData = null
}
errorContent = { message: externalResponse.statusText }
}
const error = extractErrorMessage({
status: externalResponse.status,
statusText: externalResponse.statusText,
data: errorData,
})
const error = errorContent.message || `${toolId} API error: ${externalResponse.statusText}`
logger.error(`${toolId} error:`, { error })
throw new Error(error)
}

View File

@@ -96,3 +96,23 @@ export function buildMeetingOutputs(): Record<string, TriggerOutput> {
},
} as Record<string, TriggerOutput>
}
/**
* Build output schema for generic webhook events
*/
export function buildGenericOutputs(): Record<string, TriggerOutput> {
return {
payload: {
type: 'object',
description: 'Raw webhook payload',
},
headers: {
type: 'object',
description: 'Request headers',
},
timestamp: {
type: 'string',
description: 'ISO8601 received timestamp',
},
} as Record<string, TriggerOutput>
}

View File

@@ -1,6 +1,6 @@
import { CirclebackIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
import { buildMeetingOutputs, circlebackSetupInstructions, circlebackTriggerOptions } from './utils'
import { buildGenericOutputs, circlebackSetupInstructions, circlebackTriggerOptions } from './utils'
export const circlebackWebhookTrigger: TriggerConfig = {
id: 'circleback_webhook',
@@ -74,7 +74,7 @@ export const circlebackWebhookTrigger: TriggerConfig = {
},
],
outputs: buildMeetingOutputs(),
outputs: buildGenericOutputs(),
webhook: {
method: 'POST',

View File

@@ -31,14 +31,8 @@ export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [
/**
* Trigger-related subblock IDs that represent runtime metadata. They should remain
* in the workflow state but must not be modified or cleared by diff operations.
*
* Note: 'triggerConfig' is included because it's an aggregate of individual trigger
* field subblocks. Those individual fields are compared separately, so comparing
* triggerConfig would be redundant. Additionally, the client populates triggerConfig
* with default values from the trigger definition on load, which aren't present in
* the deployed state, causing false positive change detection.
*/
export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath', 'triggerConfig']
export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath']
/**
* Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled.

View File

@@ -116,11 +116,6 @@ export const githubIssueClosedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description:
'GitHub event type from X-GitHub-Event header (e.g., issues, pull_request, push)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, reopened, edited, etc.)',

View File

@@ -117,10 +117,6 @@ export const githubIssueCommentTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., issue_comment)',
},
action: {
type: 'string',
description: 'Action performed (created, edited, deleted)',

View File

@@ -137,11 +137,6 @@ export const githubIssueOpenedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description:
'GitHub event type from X-GitHub-Event header (e.g., issues, pull_request, push)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, reopened, edited, etc.)',

View File

@@ -117,10 +117,6 @@ export const githubPRClosedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, synchronize, reopened, edited, etc.)',

View File

@@ -117,10 +117,6 @@ export const githubPRCommentTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., issue_comment)',
},
action: {
type: 'string',
description: 'Action performed (created, edited, deleted)',

View File

@@ -116,10 +116,6 @@ export const githubPRMergedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, synchronize, reopened, edited, etc.)',

View File

@@ -116,10 +116,6 @@ export const githubPROpenedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request)',
},
action: {
type: 'string',
description: 'Action performed (opened, closed, synchronize, reopened, edited, etc.)',

View File

@@ -117,10 +117,6 @@ export const githubPRReviewedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., pull_request_review)',
},
action: {
type: 'string',
description: 'Action performed (submitted, edited, dismissed)',

View File

@@ -116,14 +116,6 @@ export const githubPushTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., push)',
},
branch: {
type: 'string',
description: 'Branch name derived from ref (e.g., main from refs/heads/main)',
},
ref: {
type: 'string',
description: 'Git reference that was pushed (e.g., refs/heads/main)',

View File

@@ -116,10 +116,6 @@ export const githubReleasePublishedTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., release)',
},
action: {
type: 'string',
description:

View File

@@ -117,10 +117,6 @@ export const githubWorkflowRunTrigger: TriggerConfig = {
],
outputs: {
event_type: {
type: 'string',
description: 'GitHub event type from X-GitHub-Event header (e.g., workflow_run)',
},
action: {
type: 'string',
description: 'Action performed (requested, in_progress, completed)',

View File

@@ -265,6 +265,11 @@ function buildBaseWebhookOutputs(): Record<string, TriggerOutput> {
},
},
},
webhook: {
type: 'json',
description: 'Webhook metadata including provider, path, and raw payload',
},
}
}

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailBouncedOutputs,
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailBouncedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_bounced'),
}),
outputs: buildEmailBouncedOutputs(),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailClickedOutputs,
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailClickedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_clicked'),
}),
outputs: buildEmailClickedOutputs(),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailOpenedOutputs,
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailOpenedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_opened'),
}),
outputs: buildEmailOpenedOutputs(),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailRepliedOutputs,
buildEmailReplyOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -30,7 +30,7 @@ export const lemlistEmailRepliedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_replied'),
}),
outputs: buildEmailRepliedOutputs(),
outputs: buildEmailReplyOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildEmailSentOutputs,
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistEmailSentTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_email_sent'),
}),
outputs: buildEmailSentOutputs(),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildInterestOutputs,
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistInterestedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_interested'),
}),
outputs: buildInterestOutputs(),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',

View File

@@ -2,7 +2,7 @@ import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLemlistExtraFields,
buildLinkedInRepliedOutputs,
buildLinkedInReplyOutputs,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
@@ -27,7 +27,7 @@ export const lemlistLinkedInRepliedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_linkedin_replied'),
}),
outputs: buildLinkedInRepliedOutputs(),
outputs: buildLinkedInReplyOutputs(),
webhook: {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildInterestOutputs,
buildActivityOutputs,
buildLemlistExtraFields,
lemlistSetupInstructions,
lemlistTriggerOptions,
@@ -27,7 +27,7 @@ export const lemlistNotInterestedTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_not_interested'),
}),
outputs: buildInterestOutputs(),
outputs: buildActivityOutputs(),
webhook: {
method: 'POST',

View File

@@ -66,254 +66,203 @@ export function buildLemlistExtraFields(triggerId: string) {
}
/**
* Core fields present in ALL Lemlist webhook payloads
* See: https://help.lemlist.com/en/articles/9423940-use-the-api-to-list-activity-types
* Base activity outputs shared across all Lemlist triggers
*/
const coreOutputs = {
_id: {
type: 'string',
description: 'Unique activity identifier',
},
type: {
type: 'string',
description: 'Activity type (e.g., emailsSent, emailsReplied)',
},
createdAt: {
type: 'string',
description: 'Activity creation timestamp (ISO 8601)',
},
teamId: {
type: 'string',
description: 'Lemlist team identifier',
},
leadId: {
type: 'string',
description: 'Lead identifier',
},
campaignId: {
type: 'string',
description: 'Campaign identifier',
},
campaignName: {
type: 'string',
description: 'Campaign name',
},
} as const
/**
* Lead fields present in webhook payloads
*/
const leadOutputs = {
email: {
type: 'string',
description: 'Lead email address',
},
firstName: {
type: 'string',
description: 'Lead first name',
},
lastName: {
type: 'string',
description: 'Lead last name',
},
companyName: {
type: 'string',
description: 'Lead company name',
},
linkedinUrl: {
type: 'string',
description: 'Lead LinkedIn profile URL',
},
} as const
/**
* Sequence/campaign tracking fields for email activities
*/
const sequenceOutputs = {
sequenceId: {
type: 'string',
description: 'Sequence identifier',
},
sequenceStep: {
type: 'number',
description: 'Current step in the sequence (0-indexed)',
},
totalSequenceStep: {
type: 'number',
description: 'Total number of steps in the sequence',
},
isFirst: {
type: 'boolean',
description: 'Whether this is the first activity of this type for this step',
},
} as const
/**
* Sender information fields
*/
const senderOutputs = {
sendUserId: {
type: 'string',
description: 'Sender user identifier',
},
sendUserEmail: {
type: 'string',
description: 'Sender email address',
},
sendUserName: {
type: 'string',
description: 'Sender display name',
},
} as const
/**
* Email content fields
*/
const emailContentOutputs = {
subject: {
type: 'string',
description: 'Email subject line',
},
text: {
type: 'string',
description: 'Email body content (HTML)',
},
messageId: {
type: 'string',
description: 'Email message ID (RFC 2822 format)',
},
emailId: {
type: 'string',
description: 'Lemlist email identifier',
},
} as const
/**
* Build outputs for email sent events
*/
export function buildEmailSentOutputs(): Record<string, TriggerOutput> {
function buildBaseActivityOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...emailContentOutputs,
} as Record<string, TriggerOutput>
type: {
type: 'string',
description: 'Activity type (emailsReplied, linkedinReplied, interested, emailsOpened, etc.)',
},
_id: {
type: 'string',
description: 'Unique activity identifier',
},
leadId: {
type: 'string',
description: 'Associated lead ID',
},
campaignId: {
type: 'string',
description: 'Campaign ID',
},
campaignName: {
type: 'string',
description: 'Campaign name',
},
sequenceId: {
type: 'string',
description: 'Sequence ID within the campaign',
},
stepId: {
type: 'string',
description: 'Step ID that triggered this activity',
},
createdAt: {
type: 'string',
description: 'When the activity occurred (ISO 8601)',
},
}
}
/**
* Build outputs for email replied events
* Lead outputs - information about the lead
*/
export function buildEmailRepliedOutputs(): Record<string, TriggerOutput> {
function buildLeadOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...emailContentOutputs,
} as Record<string, TriggerOutput>
lead: {
_id: {
type: 'string',
description: 'Lead unique identifier',
},
email: {
type: 'string',
description: 'Lead email address',
},
firstName: {
type: 'string',
description: 'Lead first name',
},
lastName: {
type: 'string',
description: 'Lead last name',
},
companyName: {
type: 'string',
description: 'Lead company name',
},
phone: {
type: 'string',
description: 'Lead phone number',
},
linkedinUrl: {
type: 'string',
description: 'Lead LinkedIn profile URL',
},
picture: {
type: 'string',
description: 'Lead profile picture URL',
},
icebreaker: {
type: 'string',
description: 'Personalized icebreaker text',
},
timezone: {
type: 'string',
description: 'Lead timezone (e.g., America/New_York)',
},
isUnsubscribed: {
type: 'boolean',
description: 'Whether the lead is unsubscribed',
},
},
}
}
/**
* Build outputs for email opened events
* Standard activity outputs (activity + lead data)
*/
export function buildEmailOpenedOutputs(): Record<string, TriggerOutput> {
export function buildActivityOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
webhook: {
type: 'json',
description: 'Full webhook payload with all activity-specific data',
},
}
}
/**
* Email-specific outputs (includes message content for replies)
*/
export function buildEmailReplyOutputs(): Record<string, TriggerOutput> {
return {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
messageId: {
type: 'string',
description: 'Email message ID that was opened',
description: 'Email message ID',
},
} as Record<string, TriggerOutput>
}
/**
* Build outputs for email clicked events
*/
export function buildEmailClickedOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
messageId: {
subject: {
type: 'string',
description: 'Email message ID containing the clicked link',
description: 'Email subject line',
},
clickedUrl: {
type: 'string',
description: 'URL that was clicked',
},
} as Record<string, TriggerOutput>
}
/**
* Build outputs for email bounced events
*/
export function buildEmailBouncedOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
messageId: {
type: 'string',
description: 'Email message ID that bounced',
},
errorMessage: {
type: 'string',
description: 'Bounce error message',
},
} as Record<string, TriggerOutput>
}
/**
* Build outputs for LinkedIn replied events
*/
export function buildLinkedInRepliedOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
text: {
type: 'string',
description: 'LinkedIn message content',
description: 'Email reply text content',
},
} as Record<string, TriggerOutput>
html: {
type: 'string',
description: 'Email reply HTML content',
},
sentAt: {
type: 'string',
description: 'When the reply was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all email data',
},
}
}
/**
* Build outputs for interested/not interested events
* LinkedIn-specific outputs (includes message content)
*/
export function buildInterestOutputs(): Record<string, TriggerOutput> {
export function buildLinkedInReplyOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
} as Record<string, TriggerOutput>
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
messageId: {
type: 'string',
description: 'LinkedIn message ID',
},
text: {
type: 'string',
description: 'LinkedIn message text content',
},
sentAt: {
type: 'string',
description: 'When the message was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all LinkedIn data',
},
}
}
/**
* Build outputs for generic webhook (all events)
* Includes all possible fields across event types
* All outputs for generic webhook (activity + lead + all possible fields)
*/
export function buildLemlistOutputs(): Record<string, TriggerOutput> {
export function buildAllOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...leadOutputs,
...sequenceOutputs,
...senderOutputs,
...emailContentOutputs,
clickedUrl: {
...buildBaseActivityOutputs(),
...buildLeadOutputs(),
messageId: {
type: 'string',
description: 'URL that was clicked (for emailsClicked events)',
description: 'Message ID (for email/LinkedIn events)',
},
errorMessage: {
subject: {
type: 'string',
description: 'Error message (for bounce/failed events)',
description: 'Email subject (for email events)',
},
} as Record<string, TriggerOutput>
text: {
type: 'string',
description: 'Message text content',
},
html: {
type: 'string',
description: 'Message HTML content (for email events)',
},
sentAt: {
type: 'string',
description: 'When the message was sent',
},
webhook: {
type: 'json',
description: 'Full webhook payload with all data',
},
}
}

View File

@@ -1,8 +1,8 @@
import { LemlistIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildAllOutputs,
buildLemlistExtraFields,
buildLemlistOutputs,
lemlistSetupInstructions,
lemlistTriggerOptions,
} from '@/triggers/lemlist/utils'
@@ -27,7 +27,7 @@ export const lemlistWebhookTrigger: TriggerConfig = {
extraFields: buildLemlistExtraFields('lemlist_webhook'),
}),
outputs: buildLemlistOutputs(),
outputs: buildAllOutputs(),
webhook: {
method: 'POST',

View File

@@ -110,7 +110,6 @@ export const telegramWebhookTrigger: TriggerConfig = {
},
sender: {
id: { type: 'number', description: 'Sender user ID' },
username: { type: 'string', description: 'Sender username (if available)' },
firstName: { type: 'string', description: 'Sender first name' },
lastName: { type: 'string', description: 'Sender last name' },
languageCode: { type: 'string', description: 'Sender language code (if available)' },

View File

@@ -136,8 +136,6 @@ export const typeformWebhookTrigger: TriggerConfig = {
'Array of respondent answers (only includes answered questions). Each answer contains type, value, and field reference.',
},
definition: {
description:
'Form definition (only included when "Include Form Definition" is enabled in trigger settings)',
id: {
type: 'string',
description: 'Form ID',

View File

@@ -96,6 +96,10 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the event occurred',
},
workspaceId: {
type: 'string',
description: 'The workspace ID where the event occurred',
},
collectionId: {
type: 'string',
description: 'The collection ID where the item was changed',

View File

@@ -109,6 +109,10 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the event occurred',
},
workspaceId: {
type: 'string',
description: 'The workspace ID where the event occurred',
},
collectionId: {
type: 'string',
description: 'The collection ID where the item was created',

View File

@@ -97,6 +97,10 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the event occurred',
},
workspaceId: {
type: 'string',
description: 'The workspace ID where the event occurred',
},
collectionId: {
type: 'string',
description: 'The collection ID where the item was deleted',

View File

@@ -76,9 +76,9 @@ export const webflowFormSubmissionTrigger: TriggerConfig = {
type: 'string',
description: 'The site ID where the form was submitted',
},
formId: {
workspaceId: {
type: 'string',
description: 'The form ID',
description: 'The workspace ID where the event occurred',
},
name: {
type: 'string',

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",